From d7b96773fd4024a10203f9e99be29fd358a54a13 Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 25 Jan 2026 23:24:33 +0200 Subject: [PATCH] fix: better type-safety --- ...486e71b9b65a7dd923b5354c5a4dde98332eb.json | 27 + ...417f2966fcbd688be7434626ae5f6971fa1f.json} | 6 +- ...88a04ddd035ec9e88ef5f207e2487a02a1224.json | 28 + ...6ee3f54448e3c24943f7005e3a8e28820d43.json} | 22 +- ...b9a137be4dcce28e9293551c8c3fd40fdd86.json} | 10 +- ...97c248fbf7360e510d703b9eec42efe9e049e.json | 22 - ...9e1820172fd06916d225ca6c5137a507b8fa.json} | 20 +- ...e68ef8470a9751fcaefe5fd2d7909dead54b.json} | 6 +- ...b7deb7e58f84310690c7bde61e1834dc5903.json} | 6 +- ...df5003f804c43ad253d6476523eacdebf811.json} | 6 +- ...245b14e15f81a42d7e0c4c428b65f466af5ee.json | 52 ++ ...34ffc19430a6b16adb0c49307da0cacdf1ca3.json | 40 -- ...c8afd10478f41e22307014784b4ee3740b4a.json} | 6 +- Cargo.lock | 5 +- Cargo.toml | 1 + .../src/channel_verification.rs | 51 ++ crates/tranquil-db-traits/src/delegation.rs | 11 +- crates/tranquil-db-traits/src/infra.rs | 70 ++- crates/tranquil-db-traits/src/invite_code.rs | 56 ++ crates/tranquil-db-traits/src/lib.rs | 49 +- crates/tranquil-db-traits/src/oauth.rs | 59 +- crates/tranquil-db-traits/src/repo.rs | 170 +++++- crates/tranquil-db-traits/src/scope.rs | 180 ++++++ crates/tranquil-db-traits/src/sequence.rs | 73 +++ crates/tranquil-db-traits/src/session.rs | 122 +++- crates/tranquil-db-traits/src/sso.rs | 155 ++++- crates/tranquil-db-traits/src/user.rs | 134 +++-- crates/tranquil-db/src/postgres/delegation.rs | 18 +- crates/tranquil-db/src/postgres/infra.rs | 52 +- crates/tranquil-db/src/postgres/oauth.rs | 175 ++++-- crates/tranquil-db/src/postgres/repo.rs | 253 ++++---- crates/tranquil-db/src/postgres/session.rs | 75 +-- crates/tranquil-db/src/postgres/sso.rs | 61 +- crates/tranquil-db/src/postgres/user.rs | 111 ++-- crates/tranquil-oauth/src/lib.rs | 10 +- crates/tranquil-oauth/src/types.rs | 405 ++++++++----- crates/tranquil-pds/Cargo.toml | 1 + .../src/api/admin/account/delete.rs | 22 +- .../src/api/admin/account/email.rs | 12 +- .../src/api/admin/account/info.rs | 19 +- .../src/api/admin/account/search.rs | 8 +- .../src/api/admin/account/update.rs | 6 +- crates/tranquil-pds/src/api/admin/config.rs | 64 +- crates/tranquil-pds/src/api/admin/invite.rs | 9 +- crates/tranquil-pds/src/api/admin/status.rs | 28 +- crates/tranquil-pds/src/api/age_assurance.rs | 4 +- crates/tranquil-pds/src/api/delegation.rs | 182 +++--- crates/tranquil-pds/src/api/error.rs | 19 + .../tranquil-pds/src/api/identity/account.rs | 105 ++-- crates/tranquil-pds/src/api/identity/did.rs | 58 +- .../src/api/identity/plc/request.rs | 19 +- .../tranquil-pds/src/api/identity/plc/sign.rs | 16 +- .../src/api/identity/plc/submit.rs | 14 +- .../src/api/notification_prefs.rs | 60 +- crates/tranquil-pds/src/api/proxy.rs | 11 +- crates/tranquil-pds/src/api/repo/blob.rs | 46 +- crates/tranquil-pds/src/api/repo/import.rs | 20 +- crates/tranquil-pds/src/api/repo/meta.rs | 4 +- .../tranquil-pds/src/api/repo/record/batch.rs | 201 +++---- .../src/api/repo/record/delete.rs | 27 +- .../tranquil-pds/src/api/repo/record/mod.rs | 5 + .../src/api/repo/record/pagination.rs | 31 + .../tranquil-pds/src/api/repo/record/read.rs | 14 +- .../tranquil-pds/src/api/repo/record/utils.rs | 40 +- .../src/api/repo/record/validation_mode.rs | 35 ++ .../tranquil-pds/src/api/repo/record/write.rs | 128 ++-- .../src/api/server/account_status.rs | 63 +- .../src/api/server/app_password.rs | 70 +-- crates/tranquil-pds/src/api/server/email.rs | 113 +--- crates/tranquil-pds/src/api/server/invite.rs | 16 +- crates/tranquil-pds/src/api/server/logo.rs | 2 +- crates/tranquil-pds/src/api/server/meta.rs | 5 +- .../tranquil-pds/src/api/server/migration.rs | 19 +- .../src/api/server/passkey_account.rs | 125 ++-- .../tranquil-pds/src/api/server/passkeys.rs | 76 +-- .../tranquil-pds/src/api/server/password.rs | 234 ++------ crates/tranquil-pds/src/api/server/reauth.rs | 81 +-- .../src/api/server/service_auth.rs | 6 +- crates/tranquil-pds/src/api/server/session.rs | 249 +++----- crates/tranquil-pds/src/api/server/totp.rs | 234 +++----- .../src/api/server/trusted_devices.rs | 17 +- .../src/api/server/verify_email.rs | 5 +- .../src/api/server/verify_token.rs | 68 +-- .../tranquil-pds/src/auth/account_verified.rs | 61 ++ .../tranquil-pds/src/auth/auth_extractor.rs | 547 ------------------ crates/tranquil-pds/src/auth/extractor.rs | 40 +- .../tranquil-pds/src/auth/login_identifier.rs | 135 +++++ crates/tranquil-pds/src/auth/mfa_verified.rs | 234 ++++++++ crates/tranquil-pds/src/auth/mod.rs | 27 +- .../tranquil-pds/src/auth/scope_verified.rs | 502 ++++++++++++++++ crates/tranquil-pds/src/auth/service.rs | 19 +- crates/tranquil-pds/src/auth/webauthn.rs | 2 +- crates/tranquil-pds/src/cid_types.rs | 101 ++++ crates/tranquil-pds/src/comms/service.rs | 24 +- crates/tranquil-pds/src/crawlers.rs | 24 +- crates/tranquil-pds/src/delegation/mod.rs | 10 +- crates/tranquil-pds/src/delegation/roles.rs | 108 ++++ crates/tranquil-pds/src/delegation/scopes.rs | 36 +- crates/tranquil-pds/src/lib.rs | 3 +- crates/tranquil-pds/src/main.rs | 91 +-- .../src/oauth/endpoints/authorize.rs | 444 ++++---------- .../src/oauth/endpoints/delegation.rs | 96 +-- .../src/oauth/endpoints/metadata.rs | 5 +- .../tranquil-pds/src/oauth/endpoints/par.rs | 108 ++-- .../src/oauth/endpoints/token/grants.rs | 91 ++- .../src/oauth/endpoints/token/helpers.rs | 3 +- .../src/oauth/endpoints/token/introspect.rs | 30 +- .../src/oauth/endpoints/token/mod.rs | 29 +- crates/tranquil-pds/src/oauth/mod.rs | 16 +- crates/tranquil-pds/src/oauth/verify.rs | 34 +- .../tranquil-pds/src/rate_limit/extractor.rs | 272 +++++++++ .../src/{rate_limit.rs => rate_limit/mod.rs} | 107 +--- crates/tranquil-pds/src/scheduled.rs | 34 +- crates/tranquil-pds/src/sso/config.rs | 3 +- crates/tranquil-pds/src/sso/endpoints.rs | 212 ++++--- crates/tranquil-pds/src/state.rs | 18 +- crates/tranquil-pds/src/sync/commit.rs | 7 +- crates/tranquil-pds/src/sync/deprecated.rs | 9 +- crates/tranquil-pds/src/sync/frame.rs | 17 +- crates/tranquil-pds/src/sync/listener.rs | 31 +- crates/tranquil-pds/src/sync/mod.rs | 4 +- .../tranquil-pds/src/sync/subscribe_repos.rs | 18 +- crates/tranquil-pds/src/sync/util.rs | 120 +--- crates/tranquil-pds/src/util.rs | 24 +- crates/tranquil-pds/tests/commit_signing.rs | 2 +- crates/tranquil-pds/tests/common/mod.rs | 3 +- .../tranquil-pds/tests/firehose_validation.rs | 16 +- crates/tranquil-pds/tests/shutdown_unit.rs | 71 +++ crates/tranquil-pds/tests/sso.rs | 42 +- crates/tranquil-scopes/src/parser.rs | 4 +- crates/tranquil-types/src/lib.rs | 9 +- 131 files changed, 4999 insertions(+), 3982 deletions(-) create mode 100644 .sqlx/query-03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb.json rename .sqlx/{query-805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651.json => query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json} (81%) create mode 100644 .sqlx/query-200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224.json rename .sqlx/{query-426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded.json => query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json} (59%) rename .sqlx/{query-9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671.json => query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json} (77%) delete mode 100644 .sqlx/query-36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e.json rename .sqlx/{query-445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015.json => query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json} (80%) rename .sqlx/{query-605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc.json => query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json} (77%) rename .sqlx/{query-e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23.json => query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json} (78%) rename .sqlx/{query-8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec.json => query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json} (79%) create mode 100644 .sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json delete mode 100644 .sqlx/query-e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3.json rename .sqlx/{query-caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db.json => query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json} (78%) create mode 100644 crates/tranquil-db-traits/src/channel_verification.rs create mode 100644 crates/tranquil-db-traits/src/invite_code.rs create mode 100644 crates/tranquil-db-traits/src/scope.rs create mode 100644 crates/tranquil-db-traits/src/sequence.rs create mode 100644 crates/tranquil-pds/src/api/repo/record/pagination.rs create mode 100644 crates/tranquil-pds/src/api/repo/record/validation_mode.rs create mode 100644 crates/tranquil-pds/src/auth/account_verified.rs delete mode 100644 crates/tranquil-pds/src/auth/auth_extractor.rs create mode 100644 crates/tranquil-pds/src/auth/login_identifier.rs create mode 100644 crates/tranquil-pds/src/auth/mfa_verified.rs create mode 100644 crates/tranquil-pds/src/auth/scope_verified.rs create mode 100644 crates/tranquil-pds/src/cid_types.rs create mode 100644 crates/tranquil-pds/src/delegation/roles.rs create mode 100644 crates/tranquil-pds/src/rate_limit/extractor.rs rename crates/tranquil-pds/src/{rate_limit.rs => rate_limit/mod.rs} (70%) create mode 100644 crates/tranquil-pds/tests/shutdown_unit.rs diff --git a/.sqlx/query-03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb.json b/.sqlx/query-03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb.json new file mode 100644 index 0000000..9b00860 --- /dev/null +++ b/.sqlx/query-03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb" +} diff --git a/.sqlx/query-805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651.json b/.sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json similarity index 81% rename from .sqlx/query-805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651.json rename to .sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json index 50dc297..14a2d09 100644 --- a/.sqlx/query-805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651.json +++ b/.sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", "describe": { "columns": [ { @@ -20,7 +20,7 @@ }, { "ordinal": 3, - "name": "event_type", + "name": "event_type: RepoEventType", "type_info": "Text" }, { @@ -96,5 +96,5 @@ true ] }, - "hash": "805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651" + "hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f" } diff --git a/.sqlx/query-200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224.json b/.sqlx/query-200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224.json new file mode 100644 index 0000000..34bd163 --- /dev/null +++ b/.sqlx/query-200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT available_uses, COALESCE(disabled, false) as \"disabled!\" FROM invite_codes WHERE code = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "available_uses", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "disabled!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224" +} diff --git a/.sqlx/query-426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded.json b/.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json similarity index 59% rename from .sqlx/query-426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded.json rename to .sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json index 9754d26..ac92ad5 100644 --- a/.sqlx/query-426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded.json +++ b/.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n email,\n preferred_comms_channel::text as \"preferred_channel!\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1", + "query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1", "describe": { "columns": [ { @@ -10,8 +10,20 @@ }, { "ordinal": 1, - "name": "preferred_channel!", - "type_info": "Text" + "name": "preferred_channel!: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } }, { "ordinal": 2, @@ -51,7 +63,7 @@ }, "nullable": [ true, - null, + false, true, false, true, @@ -60,5 +72,5 @@ false ] }, - "hash": "426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded" + "hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43" } diff --git a/.sqlx/query-9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671.json b/.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json similarity index 77% rename from .sqlx/query-9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671.json rename to .sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json index 010464d..b0716fa 100644 --- a/.sqlx/query-9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671.json +++ b/.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ", + "query": "\n SELECT\n created_at,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "channel: String", + "name": "channel: CommsChannel", "type_info": { "Custom": { "name": "comms_channel", @@ -27,7 +27,7 @@ }, { "ordinal": 2, - "name": "comms_type: String", + "name": "comms_type: CommsType", "type_info": { "Custom": { "name": "comms_type", @@ -52,7 +52,7 @@ }, { "ordinal": 3, - "name": "status: String", + "name": "status: CommsStatus", "type_info": { "Custom": { "name": "comms_status", @@ -93,5 +93,5 @@ false ] }, - "hash": "9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671" + "hash": "25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86" } diff --git a/.sqlx/query-36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e.json b/.sqlx/query-36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e.json deleted file mode 100644 index 2cece17..0000000 --- a/.sqlx/query-36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as \"valid!\" FROM invite_codes WHERE code = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "valid!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e" -} diff --git a/.sqlx/query-445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015.json b/.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json similarity index 80% rename from .sqlx/query-445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015.json rename to .sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json index 05940b5..793d325 100644 --- a/.sqlx/query-445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015.json +++ b/.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", "describe": { "columns": [ { @@ -82,8 +82,18 @@ }, { "ordinal": 13, - "name": "account_type!", - "type_info": "Text" + "name": "account_type!: AccountType", + "type_info": { + "Custom": { + "name": "account_type", + "kind": { + "Enum": [ + "personal", + "delegated" + ] + } + } + } } ], "parameters": { @@ -105,8 +115,8 @@ false, false, false, - null + false ] }, - "hash": "445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015" + "hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa" } diff --git a/.sqlx/query-605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc.json b/.sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json similarity index 77% rename from .sqlx/query-605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc.json rename to .sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json index 72904a3..48ebe1f 100644 --- a/.sqlx/query-605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc.json +++ b/.sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", "describe": { "columns": [ { @@ -20,7 +20,7 @@ }, { "ordinal": 3, - "name": "event_type", + "name": "event_type: RepoEventType", "type_info": "Text" }, { @@ -97,5 +97,5 @@ true ] }, - "hash": "605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc" + "hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b" } diff --git a/.sqlx/query-e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23.json b/.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json similarity index 78% rename from .sqlx/query-e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23.json rename to .sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json index cc05739..eb74ec8 100644 --- a/.sqlx/query-e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23.json +++ b/.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", "describe": { "columns": [ { @@ -20,7 +20,7 @@ }, { "ordinal": 3, - "name": "event_type", + "name": "event_type: RepoEventType", "type_info": "Text" }, { @@ -96,5 +96,5 @@ true ] }, - "hash": "e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23" + "hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903" } diff --git a/.sqlx/query-8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec.json b/.sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json similarity index 79% rename from .sqlx/query-8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec.json rename to .sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json index 040001b..b4fe68c 100644 --- a/.sqlx/query-8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec.json +++ b/.sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", "describe": { "columns": [ { @@ -20,7 +20,7 @@ }, { "ordinal": 3, - "name": "event_type", + "name": "event_type: RepoEventType", "type_info": "Text" }, { @@ -97,5 +97,5 @@ true ] }, - "hash": "8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec" + "hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811" } diff --git a/.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json b/.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json new file mode 100644 index 0000000..cef549d --- /dev/null +++ b/.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "preferred_channel!: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "preferred_locale", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + false, + false, + true + ] + }, + "hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee" +} diff --git a/.sqlx/query-e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3.json b/.sqlx/query-e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3.json deleted file mode 100644 index 113b228..0000000 --- a/.sqlx/query-e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, handle, preferred_comms_channel::text as \"preferred_channel!\", preferred_locale\n FROM users WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "handle", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "preferred_channel!", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "preferred_locale", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - false, - null, - true - ] - }, - "hash": "e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3" -} diff --git a/.sqlx/query-caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db.json b/.sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json similarity index 78% rename from .sqlx/query-caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db.json rename to .sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json index fb2128f..01108ef 100644 --- a/.sqlx/query-caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db.json +++ b/.sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", "describe": { "columns": [ { @@ -20,7 +20,7 @@ }, { "ordinal": 3, - "name": "event_type", + "name": "event_type: RepoEventType", "type_info": "Text" }, { @@ -97,5 +97,5 @@ true ] }, - "hash": "caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db" + "hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a" } diff --git a/Cargo.lock b/Cargo.lock index 976d754..a760f82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5733,9 +5733,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -6115,6 +6115,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-tungstenite", + "tokio-util", "tower", "tower-http", "tower-layer", diff --git a/Cargo.toml b/Cargo.toml index 76f9429..048ee0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid" subtle = "2.5" thiserror = "2.0" tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process"] } +tokio-util = "0.7.18" tokio-tungstenite = { version = "0.28", features = ["native-tls"] } totp-rs = { version = "5", features = ["qr"] } tower = "0.5" diff --git a/crates/tranquil-db-traits/src/channel_verification.rs b/crates/tranquil-db-traits/src/channel_verification.rs new file mode 100644 index 0000000..d671e89 --- /dev/null +++ b/crates/tranquil-db-traits/src/channel_verification.rs @@ -0,0 +1,51 @@ +use crate::CommsChannel; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChannelVerificationStatus { + pub email: bool, + pub discord: bool, + pub telegram: bool, + pub signal: bool, +} + +impl ChannelVerificationStatus { + pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self { + Self { + email, + discord, + telegram, + signal, + } + } + + pub fn has_any_verified(&self) -> bool { + self.email || self.discord || self.telegram || self.signal + } + + pub fn verified_channels(&self) -> Vec { + let mut channels = Vec::with_capacity(4); + if self.email { + channels.push(CommsChannel::Email); + } + if self.discord { + channels.push(CommsChannel::Discord); + } + if self.telegram { + channels.push(CommsChannel::Telegram); + } + if self.signal { + channels.push(CommsChannel::Signal); + } + channels + } + + pub fn is_verified(&self, channel: CommsChannel) -> bool { + match channel { + CommsChannel::Email => self.email, + CommsChannel::Discord => self.discord, + CommsChannel::Telegram => self.telegram, + CommsChannel::Signal => self.signal, + } + } +} diff --git a/crates/tranquil-db-traits/src/delegation.rs b/crates/tranquil-db-traits/src/delegation.rs index 781bbf7..56a36b9 100644 --- a/crates/tranquil-db-traits/src/delegation.rs +++ b/crates/tranquil-db-traits/src/delegation.rs @@ -5,13 +5,14 @@ use tranquil_types::{Did, Handle}; use uuid::Uuid; use crate::DbError; +use crate::scope::DbScope; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegationGrant { pub id: Uuid, pub delegated_did: Did, pub controller_did: Did, - pub granted_scopes: String, + pub granted_scopes: DbScope, pub granted_at: DateTime, pub granted_by: Did, pub revoked_at: Option>, @@ -22,7 +23,7 @@ pub struct DelegationGrant { pub struct DelegatedAccountInfo { pub did: Did, pub handle: Handle, - pub granted_scopes: String, + pub granted_scopes: DbScope, pub granted_at: DateTime, } @@ -30,7 +31,7 @@ pub struct DelegatedAccountInfo { pub struct ControllerInfo { pub did: Did, pub handle: Handle, - pub granted_scopes: String, + pub granted_scopes: DbScope, pub granted_at: DateTime, pub is_active: bool, } @@ -67,7 +68,7 @@ pub trait DelegationRepository: Send + Sync { &self, delegated_did: &Did, controller_did: &Did, - granted_scopes: &str, + granted_scopes: &DbScope, granted_by: &Did, ) -> Result; @@ -82,7 +83,7 @@ pub trait DelegationRepository: Send + Sync { &self, delegated_did: &Did, controller_did: &Did, - new_scopes: &str, + new_scopes: &DbScope, ) -> Result; async fn get_delegation( diff --git a/crates/tranquil-db-traits/src/infra.rs b/crates/tranquil-db-traits/src/infra.rs index 04d5baf..6745411 100644 --- a/crates/tranquil-db-traits/src/infra.rs +++ b/crates/tranquil-db-traits/src/infra.rs @@ -5,6 +5,7 @@ use tranquil_types::{CidLink, Did, Handle}; use uuid::Uuid; use crate::DbError; +use crate::invite_code::{InviteCodeError, ValidatedInviteCode}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum InviteCodeSortOrder { @@ -13,6 +14,45 @@ pub enum InviteCodeSortOrder { Usage, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum InviteCodeState { + #[default] + Active, + Disabled, +} + +impl InviteCodeState { + pub fn is_active(self) -> bool { + matches!(self, Self::Active) + } + + pub fn is_disabled(self) -> bool { + matches!(self, Self::Disabled) + } +} + +impl From for InviteCodeState { + fn from(disabled: bool) -> Self { + if disabled { + Self::Disabled + } else { + Self::Active + } + } +} + +impl From> for InviteCodeState { + fn from(disabled: Option) -> Self { + Self::from(disabled.unwrap_or(false)) + } +} + +impl From for bool { + fn from(state: InviteCodeState) -> Self { + matches!(state, InviteCodeState::Disabled) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "comms_channel", rename_all = "snake_case")] pub enum CommsChannel { @@ -72,7 +112,7 @@ pub struct QueuedComms { pub struct InviteCodeInfo { pub code: String, pub available_uses: i32, - pub disabled: bool, + pub state: InviteCodeState, pub for_account: Option, pub created_at: DateTime, pub created_by: Option, @@ -95,6 +135,12 @@ pub struct InviteCodeRow { pub created_at: DateTime, } +impl InviteCodeRow { + pub fn state(&self) -> InviteCodeState { + InviteCodeState::from(self.disabled) + } +} + #[derive(Debug, Clone)] pub struct ReservedSigningKey { pub id: Uuid, @@ -148,11 +194,21 @@ pub trait InfraRepository: Send + Sync { async fn get_invite_code_available_uses(&self, code: &str) -> Result, DbError>; - async fn is_invite_code_valid(&self, code: &str) -> Result; + async fn validate_invite_code<'a>( + &self, + code: &'a str, + ) -> Result, InviteCodeError>; - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>; + async fn decrement_invite_code_uses( + &self, + code: &ValidatedInviteCode<'_>, + ) -> Result<(), DbError>; - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>; + async fn record_invite_code_use( + &self, + code: &ValidatedInviteCode<'_>, + used_by_user: Uuid, + ) -> Result<(), DbError>; async fn get_invite_codes_for_account( &self, @@ -317,9 +373,9 @@ pub trait InfraRepository: Send + Sync { #[derive(Debug, Clone)] pub struct NotificationHistoryRow { pub created_at: DateTime, - pub channel: String, - pub comms_type: String, - pub status: String, + pub channel: CommsChannel, + pub comms_type: CommsType, + pub status: CommsStatus, pub subject: Option, pub body: String, } diff --git a/crates/tranquil-db-traits/src/invite_code.rs b/crates/tranquil-db-traits/src/invite_code.rs new file mode 100644 index 0000000..8232e93 --- /dev/null +++ b/crates/tranquil-db-traits/src/invite_code.rs @@ -0,0 +1,56 @@ +use std::marker::PhantomData; + +use crate::DbError; + +#[derive(Debug)] +pub struct ValidatedInviteCode<'a> { + code: &'a str, + _marker: PhantomData<&'a ()>, +} + +impl<'a> ValidatedInviteCode<'a> { + pub fn new_validated(code: &'a str) -> Self { + Self { + code, + _marker: PhantomData, + } + } + + pub fn code(&self) -> &str { + self.code + } +} + +#[derive(Debug)] +pub enum InviteCodeError { + NotFound, + ExhaustedUses, + Disabled, + DatabaseError(DbError), +} + +impl std::fmt::Display for InviteCodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "Invite code not found"), + Self::ExhaustedUses => write!(f, "Invite code has no remaining uses"), + Self::Disabled => write!(f, "Invite code is disabled"), + Self::DatabaseError(e) => write!(f, "Database error: {}", e), + } + } +} + +impl std::error::Error for InviteCodeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DatabaseError(e) => Some(e), + _ => None, + } + } +} + +impl From for InviteCodeError { + fn from(e: DbError) -> Self { + Self::DatabaseError(e) + } +} diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index bf8e086..3fce715 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -1,11 +1,15 @@ mod backlink; mod backup; mod blob; +mod channel_verification; mod delegation; mod error; mod infra; +mod invite_code; mod oauth; mod repo; +mod scope; +mod sequence; mod session; mod sso; mod user; @@ -16,6 +20,7 @@ pub use backup::{ OldBackupInfo, UserBackupInfo, }; pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo}; +pub use channel_verification::ChannelVerificationStatus; pub use delegation::{ AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant, DelegationRepository, @@ -23,39 +28,45 @@ pub use delegation::{ pub use error::DbError; pub use infra::{ AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository, - InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow, - QueuedComms, ReservedSigningKey, + InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, + NotificationHistoryRow, QueuedComms, ReservedSigningKey, }; +pub use invite_code::{InviteCodeError, ValidatedInviteCode}; pub use oauth::{ DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup, - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; pub use repo::{ - ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, CommitEventData, - EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, RecordDelete, - RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, - RepoEventReceiver, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, RepoWithoutRev, - SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, + AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, + CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, + RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, + RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, + RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, }; +pub use scope::{DbScope, InvalidScopeError}; +pub use sequence::{SequenceNumber, deserialize_optional_sequence}; pub use session::{ - AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, - SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult, + SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, + SessionRepository, SessionToken, SessionTokenCreate, }; pub use sso::{ - ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, + ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, SsoAuthState, + SsoPendingRegistration, SsoProviderType, SsoRepository, }; pub use user::{ - AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, + AccountSearchResult, AccountType, CompletePasskeySetupInput, CreateAccountError, CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, - TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, - UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, - UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, - UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, - UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, - UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, - UserStatus, UserVerificationInfo, UserWithKey, + TotpRecord, TotpRecordState, UnverifiedTotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, + UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc, + UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, + UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, + UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, + UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification, + UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, + VerifiedTotpRecord, }; diff --git a/crates/tranquil-db-traits/src/oauth.rs b/crates/tranquil-db-traits/src/oauth.rs index b6f5d45..f24f6fc 100644 --- a/crates/tranquil-db-traits/src/oauth.rs +++ b/crates/tranquil-db-traits/src/oauth.rs @@ -10,6 +10,37 @@ use uuid::Uuid; use crate::DbError; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TokenFamilyId(i32); + +impl TokenFamilyId { + pub fn new(id: i32) -> Self { + Self(id) + } + + pub fn as_i32(self) -> i32 { + self.0 + } +} + +impl From for TokenFamilyId { + fn from(id: i32) -> Self { + Self(id) + } +} + +impl From for i32 { + fn from(id: TokenFamilyId) -> Self { + id.0 + } +} + +impl std::fmt::Display for TokenFamilyId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScopePreference { pub scope: String, @@ -53,7 +84,7 @@ pub struct DeviceTrustInfo { #[derive(Debug, Clone)] pub struct OAuthSessionListItem { - pub id: i32, + pub id: TokenFamilyId, pub token_id: TokenId, pub created_at: DateTime, pub expires_at: DateTime, @@ -62,19 +93,19 @@ pub struct OAuthSessionListItem { pub enum RefreshTokenLookup { Valid { - db_id: i32, + db_id: TokenFamilyId, token_data: TokenData, }, InGracePeriod { - db_id: i32, + db_id: TokenFamilyId, token_data: TokenData, rotated_at: DateTime, }, Used { - original_token_id: i32, + original_token_id: TokenFamilyId, }, Expired { - db_id: i32, + db_id: TokenFamilyId, }, NotFound, } @@ -93,28 +124,28 @@ impl RefreshTokenLookup { #[async_trait] pub trait OAuthRepository: Send + Sync { - async fn create_token(&self, data: &TokenData) -> Result; + async fn create_token(&self, data: &TokenData) -> Result; async fn get_token_by_id(&self, token_id: &TokenId) -> Result, DbError>; async fn get_token_by_refresh_token( &self, refresh_token: &RefreshToken, - ) -> Result, DbError>; + ) -> Result, DbError>; async fn get_token_by_previous_refresh_token( &self, refresh_token: &RefreshToken, - ) -> Result, DbError>; + ) -> Result, DbError>; async fn rotate_token( &self, - old_db_id: i32, + old_db_id: TokenFamilyId, new_refresh_token: &RefreshToken, new_expires_at: DateTime, ) -> Result<(), DbError>; async fn check_refresh_token_used( &self, refresh_token: &RefreshToken, - ) -> Result, DbError>; + ) -> Result, DbError>; async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>; - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>; + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError>; async fn list_tokens_for_user(&self, did: &Did) -> Result, DbError>; async fn count_tokens_for_user(&self, did: &Did) -> Result; async fn delete_oldest_tokens_for_user( @@ -274,7 +305,11 @@ pub trait OAuthRepository: Send + Sync { ) -> Result<(), DbError>; async fn list_sessions_by_did(&self, did: &Did) -> Result, DbError>; - async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result; + async fn delete_session_by_id( + &self, + session_id: TokenFamilyId, + did: &Did, + ) -> Result; async fn delete_sessions_by_did(&self, did: &Did) -> Result; async fn delete_sessions_by_did_except( &self, diff --git a/crates/tranquil-db-traits/src/repo.rs b/crates/tranquil-db-traits/src/repo.rs index d3d5fdd..26dae2c 100644 --- a/crates/tranquil-db-traits/src/repo.rs +++ b/crates/tranquil-db-traits/src/repo.rs @@ -5,6 +5,116 @@ use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; use uuid::Uuid; use crate::DbError; +use crate::sequence::SequenceNumber; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum RepoEventType { + Commit, + Identity, + Account, + Sync, +} + +impl RepoEventType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Commit => "commit", + Self::Identity => "identity", + Self::Account => "account", + Self::Sync => "sync", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum AccountStatus { + Active, + Takendown, + Suspended, + Deactivated, + Deleted, +} + +impl AccountStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Takendown => "takendown", + Self::Suspended => "suspended", + Self::Deactivated => "deactivated", + Self::Deleted => "deleted", + } + } + + pub fn for_firehose(&self) -> Option<&'static str> { + match self { + Self::Active => None, + other => Some(other.as_str()), + } + } + + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "active" => Some(Self::Active), + "takendown" => Some(Self::Takendown), + "suspended" => Some(Self::Suspended), + "deactivated" => Some(Self::Deactivated), + "deleted" => Some(Self::Deleted), + _ => None, + } + } + + pub fn is_active(&self) -> bool { + matches!(self, Self::Active) + } + + pub fn is_takendown(&self) -> bool { + matches!(self, Self::Takendown) + } + + pub fn is_deactivated(&self) -> bool { + matches!(self, Self::Deactivated) + } + + pub fn is_suspended(&self) -> bool { + matches!(self, Self::Suspended) + } + + pub fn is_deleted(&self) -> bool { + matches!(self, Self::Deleted) + } + + pub fn allows_read(&self) -> bool { + matches!(self, Self::Active | Self::Deactivated) + } + + pub fn allows_write(&self) -> bool { + matches!(self, Self::Active) + } + + pub fn from_db_fields( + takedown_ref: Option<&str>, + deactivated_at: Option>, + ) -> Self { + if takedown_ref.is_some() { + Self::Takendown + } else if deactivated_at.is_some() { + Self::Deactivated + } else { + Self::Active + } + } +} + +impl std::fmt::Display for AccountStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoAccountInfo { @@ -49,7 +159,7 @@ pub struct RepoWithoutRev { #[derive(Debug, Clone)] pub struct BrokenGenesisCommit { - pub seq: i64, + pub seq: SequenceNumber, pub did: Did, pub commit_cid: Option, } @@ -69,15 +179,15 @@ pub struct UserNeedingRecordBlobsBackfill { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoSeqEvent { - pub seq: i64, + pub seq: SequenceNumber, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequencedEvent { - pub seq: i64, + pub seq: SequenceNumber, pub did: Did, pub created_at: DateTime, - pub event_type: String, + pub event_type: RepoEventType, pub commit_cid: Option, pub prev_cid: Option, pub prev_data_cid: Option, @@ -86,14 +196,14 @@ pub struct SequencedEvent { pub blocks_cids: Option>, pub handle: Option, pub active: Option, - pub status: Option, + pub status: Option, pub rev: Option, } #[derive(Debug, Clone)] pub struct CommitEventData { pub did: Did, - pub event_type: String, + pub event_type: RepoEventType, pub commit_cid: Option, pub prev_cid: Option, pub ops: Option, @@ -283,27 +393,26 @@ pub trait RepoRepository: Send + Sync { async fn count_user_blocks(&self, user_id: Uuid) -> Result; - async fn insert_commit_event(&self, data: &CommitEventData) -> Result; + async fn insert_commit_event(&self, data: &CommitEventData) -> Result; async fn insert_identity_event( &self, did: &Did, handle: Option<&Handle>, - ) -> Result; + ) -> Result; async fn insert_account_event( &self, did: &Did, - active: bool, - status: Option<&str>, - ) -> Result; + status: AccountStatus, + ) -> Result; async fn insert_sync_event( &self, did: &Did, commit_cid: &CidLink, rev: Option<&str>, - ) -> Result; + ) -> Result; async fn insert_genesis_commit_event( &self, @@ -311,36 +420,49 @@ pub trait RepoRepository: Send + Sync { commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, - ) -> Result; + ) -> Result; - async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String]) - -> Result<(), DbError>; + async fn update_seq_blocks_cids( + &self, + seq: SequenceNumber, + blocks_cids: &[String], + ) -> Result<(), DbError>; - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>; + async fn delete_sequences_except( + &self, + did: &Did, + keep_seq: SequenceNumber, + ) -> Result<(), DbError>; - async fn get_max_seq(&self) -> Result; + async fn get_max_seq(&self) -> Result; - async fn get_min_seq_since(&self, since: DateTime) -> Result, DbError>; + async fn get_min_seq_since( + &self, + since: DateTime, + ) -> Result, DbError>; async fn get_account_with_repo(&self, did: &Did) -> Result, DbError>; async fn get_events_since_seq( &self, - since_seq: i64, + since_seq: SequenceNumber, limit: Option, ) -> Result, DbError>; async fn get_events_in_seq_range( &self, - start_seq: i64, - end_seq: i64, + start_seq: SequenceNumber, + end_seq: SequenceNumber, ) -> Result, DbError>; - async fn get_event_by_seq(&self, seq: i64) -> Result, DbError>; + async fn get_event_by_seq( + &self, + seq: SequenceNumber, + ) -> Result, DbError>; async fn get_events_since_cursor( &self, - cursor: i64, + cursor: SequenceNumber, limit: i64, ) -> Result, DbError>; @@ -359,7 +481,7 @@ pub trait RepoRepository: Send + Sync { async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid) -> Result, DbError>; - async fn notify_update(&self, seq: i64) -> Result<(), DbError>; + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>; async fn import_repo_data( &self, diff --git a/crates/tranquil-db-traits/src/scope.rs b/crates/tranquil-db-traits/src/scope.rs new file mode 100644 index 0000000..a6df8c4 --- /dev/null +++ b/crates/tranquil-db-traits/src/scope.rs @@ -0,0 +1,180 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbScope(String); + +impl DbScope { + pub fn new(scope: impl Into) -> Result { + let scope = scope.into(); + validate_scope_string(&scope)?; + Ok(Self(scope)) + } + + pub fn empty() -> Self { + Self(String::new()) + } + + pub fn from_db(scope: String) -> Self { + match validate_scope_string(&scope) { + Ok(()) => Self(scope), + Err(e) => panic!("corrupted scope data from database: {}", e), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Default for DbScope { + fn default() -> Self { + Self::empty() + } +} + +impl fmt::Display for DbScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for DbScope { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Serialize for DbScope { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DbScope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone)] +pub struct InvalidScopeError { + message: String, +} + +impl InvalidScopeError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for InvalidScopeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for InvalidScopeError {} + +fn validate_scope_string(scopes: &str) -> Result<(), InvalidScopeError> { + if scopes.is_empty() { + return Ok(()); + } + + scopes.split_whitespace().try_for_each(|scope| { + let base = scope.split_once('?').map_or(scope, |(b, _)| b); + if is_valid_scope_prefix(base) { + Ok(()) + } else { + Err(InvalidScopeError::new(format!("Invalid scope: {}", scope))) + } + }) +} + +fn is_valid_scope_prefix(base: &str) -> bool { + const VALID_PREFIXES: [&str; 8] = [ + "atproto", + "repo:", + "blob:", + "rpc:", + "account:", + "identity:", + "transition:", + "include:", + ]; + + VALID_PREFIXES + .iter() + .any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_scopes() { + assert!(DbScope::new("atproto").is_ok()); + assert!(DbScope::new("repo:*").is_ok()); + assert!(DbScope::new("blob:*/*").is_ok()); + assert!(DbScope::new("repo:* blob:*/*").is_ok()); + assert!(DbScope::new("").is_ok()); + assert!(DbScope::new("account:email?action=read").is_ok()); + assert!(DbScope::new("identity:handle").is_ok()); + assert!(DbScope::new("transition:generic").is_ok()); + assert!(DbScope::new("include:app.bsky.authFullApp").is_ok()); + } + + #[test] + fn test_invalid_scopes() { + assert!(DbScope::new("invalid:scope").is_err()); + assert!(DbScope::new("garbage").is_err()); + assert!(DbScope::new("repo:* invalid:scope").is_err()); + } + + #[test] + fn test_empty_scope() { + let scope = DbScope::empty(); + assert!(scope.is_empty()); + assert_eq!(scope.as_str(), ""); + } + + #[test] + fn test_display() { + let scope = DbScope::new("repo:*").unwrap(); + assert_eq!(format!("{}", scope), "repo:*"); + } + + #[test] + #[should_panic(expected = "corrupted scope data from database")] + fn test_from_db_panics_on_corrupted_data() { + DbScope::from_db("totally_invalid_garbage_scope".to_string()); + } + + #[test] + fn test_from_db_accepts_valid_data() { + let scope = DbScope::from_db("repo:* blob:*/*".to_string()); + assert_eq!(scope.as_str(), "repo:* blob:*/*"); + } +} diff --git a/crates/tranquil-db-traits/src/sequence.rs b/crates/tranquil-db-traits/src/sequence.rs new file mode 100644 index 0000000..dcce24c --- /dev/null +++ b/crates/tranquil-db-traits/src/sequence.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SequenceNumber(i64); + +impl SequenceNumber { + pub const ZERO: Self = Self(0); + pub const UNSET: Self = Self(-1); + + pub fn new(n: i64) -> Option { + if n >= 0 { Some(Self(n)) } else { None } + } + + pub fn from_raw(n: i64) -> Self { + Self(n) + } + + pub fn as_i64(&self) -> i64 { + self.0 + } + + pub fn is_valid(&self) -> bool { + self.0 >= 0 + } +} + +impl fmt::Display for SequenceNumber { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for SequenceNumber { + fn from(n: i64) -> Self { + Self(n) + } +} + +impl From for i64 { + fn from(seq: SequenceNumber) -> Self { + seq.0 + } +} + +impl Serialize for SequenceNumber { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SequenceNumber { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let n = i64::deserialize(deserializer)?; + Ok(Self(n)) + } +} + +pub fn deserialize_optional_sequence<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(SequenceNumber::from_raw)) +} diff --git a/crates/tranquil-db-traits/src/session.rs b/crates/tranquil-db-traits/src/session.rs index 1fbcbe6..035cba0 100644 --- a/crates/tranquil-db-traits/src/session.rs +++ b/crates/tranquil-db-traits/src/session.rs @@ -5,15 +5,104 @@ use uuid::Uuid; use crate::DbError; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum LoginType { + #[default] + Modern, + Legacy, +} + +impl LoginType { + pub fn is_legacy(self) -> bool { + matches!(self, Self::Legacy) + } + + pub fn is_modern(self) -> bool { + matches!(self, Self::Modern) + } +} + +impl From for LoginType { + fn from(legacy: bool) -> Self { + if legacy { Self::Legacy } else { Self::Modern } + } +} + +impl From for bool { + fn from(lt: LoginType) -> Self { + matches!(lt, LoginType::Legacy) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum AppPasswordPrivilege { + #[default] + Standard, + Privileged, +} + +impl AppPasswordPrivilege { + pub fn is_privileged(self) -> bool { + matches!(self, Self::Privileged) + } +} + +impl From for AppPasswordPrivilege { + fn from(privileged: bool) -> Self { + if privileged { + Self::Privileged + } else { + Self::Standard + } + } +} + +impl From for bool { + fn from(p: AppPasswordPrivilege) -> Self { + matches!(p, AppPasswordPrivilege::Privileged) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SessionId(i32); + +impl SessionId { + pub fn new(id: i32) -> Self { + Self(id) + } + + pub fn as_i32(self) -> i32 { + self.0 + } +} + +impl From for SessionId { + fn from(id: i32) -> Self { + Self(id) + } +} + +impl From for i32 { + fn from(id: SessionId) -> Self { + id.0 + } +} + +impl std::fmt::Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + #[derive(Debug, Clone)] pub struct SessionToken { - pub id: i32, + pub id: SessionId, pub did: Did, pub access_jti: String, pub refresh_jti: String, pub access_expires_at: DateTime, pub refresh_expires_at: DateTime, - pub legacy_login: bool, + pub login_type: LoginType, pub mfa_verified: bool, pub scope: Option, pub controller_did: Option, @@ -29,7 +118,7 @@ pub struct SessionTokenCreate { pub refresh_jti: String, pub access_expires_at: DateTime, pub refresh_expires_at: DateTime, - pub legacy_login: bool, + pub login_type: LoginType, pub mfa_verified: bool, pub scope: Option, pub controller_did: Option, @@ -38,7 +127,7 @@ pub struct SessionTokenCreate { #[derive(Debug, Clone)] pub struct SessionForRefresh { - pub id: i32, + pub id: SessionId, pub did: Did, pub scope: Option, pub controller_did: Option, @@ -48,7 +137,7 @@ pub struct SessionForRefresh { #[derive(Debug, Clone)] pub struct SessionListItem { - pub id: i32, + pub id: SessionId, pub access_jti: String, pub created_at: DateTime, pub refresh_expires_at: DateTime, @@ -61,7 +150,7 @@ pub struct AppPasswordRecord { pub name: String, pub password_hash: String, pub created_at: DateTime, - pub privileged: bool, + pub privilege: AppPasswordPrivilege, pub scopes: Option, pub created_by_controller_did: Option, } @@ -71,14 +160,14 @@ pub struct AppPasswordCreate { pub user_id: Uuid, pub name: String, pub password_hash: String, - pub privileged: bool, + pub privilege: AppPasswordPrivilege, pub scopes: Option, pub created_by_controller_did: Option, } #[derive(Debug, Clone)] pub struct SessionMfaStatus { - pub legacy_login: bool, + pub login_type: LoginType, pub mfa_verified: bool, pub last_reauth_at: Option>, } @@ -93,7 +182,7 @@ pub enum RefreshSessionResult { #[derive(Debug, Clone)] pub struct SessionRefreshData { pub old_refresh_jti: String, - pub session_id: i32, + pub session_id: SessionId, pub new_access_jti: String, pub new_refresh_jti: String, pub new_access_expires_at: DateTime, @@ -102,7 +191,7 @@ pub struct SessionRefreshData { #[async_trait] pub trait SessionRepository: Send + Sync { - async fn create_session(&self, data: &SessionTokenCreate) -> Result; + async fn create_session(&self, data: &SessionTokenCreate) -> Result; async fn get_session_by_access_jti( &self, @@ -116,7 +205,7 @@ pub trait SessionRepository: Send + Sync { async fn update_session_tokens( &self, - session_id: i32, + session_id: SessionId, new_access_jti: &str, new_refresh_jti: &str, new_access_expires_at: DateTime, @@ -125,7 +214,7 @@ pub trait SessionRepository: Send + Sync { async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result; - async fn delete_session_by_id(&self, session_id: i32) -> Result; + async fn delete_session_by_id(&self, session_id: SessionId) -> Result; async fn delete_sessions_by_did(&self, did: &Did) -> Result; @@ -139,7 +228,7 @@ pub trait SessionRepository: Send + Sync { async fn get_session_access_jti_by_id( &self, - session_id: i32, + session_id: SessionId, did: &Did, ) -> Result, DbError>; @@ -155,12 +244,15 @@ pub trait SessionRepository: Send + Sync { app_password_name: &str, ) -> Result, DbError>; - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result, DbError>; + async fn check_refresh_token_used( + &self, + refresh_jti: &str, + ) -> Result, DbError>; async fn mark_refresh_token_used( &self, refresh_jti: &str, - session_id: i32, + session_id: SessionId, ) -> Result; async fn list_app_passwords(&self, user_id: Uuid) -> Result, DbError>; diff --git a/crates/tranquil-db-traits/src/sso.rs b/crates/tranquil-db-traits/src/sso.rs index 596c561..6679a2d 100644 --- a/crates/tranquil-db-traits/src/sso.rs +++ b/crates/tranquil-db-traits/src/sso.rs @@ -6,6 +6,111 @@ use uuid::Uuid; use crate::DbError; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExternalUserId(String); + +impl ExternalUserId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl std::fmt::Display for ExternalUserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ExternalUserId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From for String { + fn from(id: ExternalUserId) -> Self { + id.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExternalUsername(String); + +impl ExternalUsername { + pub fn new(username: impl Into) -> Self { + Self(username.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl std::fmt::Display for ExternalUsername { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ExternalUsername { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From for String { + fn from(username: ExternalUsername) -> Self { + username.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExternalEmail(String); + +impl ExternalEmail { + pub fn new(email: impl Into) -> Self { + Self(email.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl std::fmt::Display for ExternalEmail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ExternalEmail { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From for String { + fn from(email: ExternalEmail) -> Self { + email.0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] pub enum SsoProviderType { @@ -17,6 +122,40 @@ pub enum SsoProviderType { Apple, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum SsoAction { + Login, + Link, + Register, +} + +impl SsoAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Login => "login", + Self::Link => "link", + Self::Register => "register", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "login" => Some(Self::Login), + "link" => Some(Self::Link), + "register" => Some(Self::Register), + _ => None, + } + } +} + +impl std::fmt::Display for SsoAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + impl SsoProviderType { pub fn as_str(&self) -> &'static str { match self { @@ -69,9 +208,9 @@ pub struct ExternalIdentity { pub id: Uuid, pub did: Did, pub provider: SsoProviderType, - pub provider_user_id: String, - pub provider_username: Option, - pub provider_email: Option, + pub provider_user_id: ExternalUserId, + pub provider_username: Option, + pub provider_email: Option, pub created_at: DateTime, pub updated_at: DateTime, pub last_login_at: Option>, @@ -82,7 +221,7 @@ pub struct SsoAuthState { pub state: String, pub request_uri: String, pub provider: SsoProviderType, - pub action: String, + pub action: SsoAction, pub nonce: Option, pub code_verifier: Option, pub did: Option, @@ -95,9 +234,9 @@ pub struct SsoPendingRegistration { pub token: String, pub request_uri: String, pub provider: SsoProviderType, - pub provider_user_id: String, - pub provider_username: Option, - pub provider_email: Option, + pub provider_user_id: ExternalUserId, + pub provider_username: Option, + pub provider_email: Option, pub provider_email_verified: bool, pub created_at: DateTime, pub expires_at: DateTime, @@ -140,7 +279,7 @@ pub trait SsoRepository: Send + Sync { state: &str, request_uri: &str, provider: SsoProviderType, - action: &str, + action: SsoAction, nonce: Option<&str>, code_verifier: Option<&str>, did: Option<&Did>, diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 5eeffcb..58a0ca4 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -1,9 +1,23 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use tranquil_types::{Did, Handle}; use uuid::Uuid; -use crate::{CommsChannel, DbError, SsoProviderType}; +use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "account_type", rename_all = "snake_case")] +pub enum AccountType { + Personal, + Delegated, +} + +impl AccountType { + pub fn is_delegated(&self) -> bool { + matches!(self, Self::Delegated) + } +} #[derive(Debug, Clone)] pub struct UserRow { @@ -62,11 +76,8 @@ pub struct UserLoginInfo { pub preferred_comms_channel: CommsChannel, pub deactivated_at: Option>, pub takedown_ref: Option, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, - pub account_type: String, + pub channel_verification: ChannelVerificationStatus, + pub account_type: AccountType, } #[derive(Debug, Clone)] @@ -74,10 +85,7 @@ pub struct User2faStatus { pub id: Uuid, pub two_factor_enabled: bool, pub preferred_comms_channel: CommsChannel, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, } #[async_trait] @@ -202,8 +210,11 @@ pub trait UserRepository: Send + Sync { did: &Did, ) -> Result, DbError>; - async fn update_preferred_comms_channel(&self, did: &Did, channel: &str) - -> Result<(), DbError>; + async fn update_preferred_comms_channel( + &self, + did: &Did, + channel: CommsChannel, + ) -> Result<(), DbError>; async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>; @@ -292,6 +303,8 @@ pub trait UserRepository: Send + Sync { async fn get_totp_record(&self, did: &Did) -> Result, DbError>; + async fn get_totp_record_state(&self, did: &Did) -> Result, DbError>; + async fn upsert_totp_secret( &self, did: &Did, @@ -560,7 +573,7 @@ pub struct DidWebOverrides { pub struct UserCommsPrefs { pub email: Option, pub handle: Handle, - pub preferred_channel: String, + pub preferred_channel: CommsChannel, pub preferred_locale: Option, } @@ -611,16 +624,13 @@ pub struct UserAuthInfo { pub password_hash: Option, pub deactivated_at: Option>, pub takedown_ref: Option, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, } #[derive(Debug, Clone)] pub struct NotificationPrefs { pub email: String, - pub preferred_channel: String, + pub preferred_channel: CommsChannel, pub discord_id: Option, pub discord_verified: bool, pub telegram_username: Option, @@ -641,10 +651,7 @@ pub struct UserVerificationInfo { pub id: Uuid, pub handle: Handle, pub email: Option, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, } #[derive(Debug, Clone)] @@ -675,6 +682,74 @@ pub struct TotpRecord { pub verified: bool, } +#[derive(Debug, Clone)] +pub struct VerifiedTotpRecord { + pub secret_encrypted: Vec, + pub encryption_version: i32, +} + +#[derive(Debug, Clone)] +pub struct UnverifiedTotpRecord { + pub secret_encrypted: Vec, + pub encryption_version: i32, +} + +#[derive(Debug, Clone)] +pub enum TotpRecordState { + Verified(VerifiedTotpRecord), + Unverified(UnverifiedTotpRecord), +} + +impl TotpRecordState { + pub fn is_verified(&self) -> bool { + matches!(self, Self::Verified(_)) + } + + pub fn as_verified(&self) -> Option<&VerifiedTotpRecord> { + match self { + Self::Verified(r) => Some(r), + Self::Unverified(_) => None, + } + } + + pub fn as_unverified(&self) -> Option<&UnverifiedTotpRecord> { + match self { + Self::Unverified(r) => Some(r), + Self::Verified(_) => None, + } + } + + pub fn into_verified(self) -> Option { + match self { + Self::Verified(r) => Some(r), + Self::Unverified(_) => None, + } + } + + pub fn into_unverified(self) -> Option { + match self { + Self::Unverified(r) => Some(r), + Self::Verified(_) => None, + } + } +} + +impl From for TotpRecordState { + fn from(record: TotpRecord) -> Self { + if record.verified { + Self::Verified(VerifiedTotpRecord { + secret_encrypted: record.secret_encrypted, + encryption_version: record.encryption_version, + }) + } else { + Self::Unverified(UnverifiedTotpRecord { + secret_encrypted: record.secret_encrypted, + encryption_version: record.encryption_version, + }) + } + } +} + #[derive(Debug, Clone)] pub struct StoredBackupCode { pub id: Uuid, @@ -685,15 +760,12 @@ pub struct StoredBackupCode { pub struct UserSessionInfo { pub handle: Handle, pub email: Option, - pub email_verified: bool, pub is_admin: bool, pub deactivated_at: Option>, pub takedown_ref: Option, pub preferred_locale: Option, pub preferred_comms_channel: CommsChannel, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, pub migrated_to_pds: Option, pub migrated_at: Option>, } @@ -713,10 +785,7 @@ pub struct UserLoginFull { pub email: Option, pub deactivated_at: Option>, pub takedown_ref: Option, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, pub allow_legacy_login: bool, pub migrated_to_pds: Option, pub preferred_comms_channel: CommsChannel, @@ -748,10 +817,7 @@ pub struct UserResendVerification { pub discord_id: Option, pub telegram_username: Option, pub signal_number: Option, - pub email_verified: bool, - pub discord_verified: bool, - pub telegram_verified: bool, - pub signal_verified: bool, + pub channel_verification: ChannelVerificationStatus, } #[derive(Debug, Clone)] diff --git a/crates/tranquil-db/src/postgres/delegation.rs b/crates/tranquil-db/src/postgres/delegation.rs index 4b849c8..659bc9c 100644 --- a/crates/tranquil-db/src/postgres/delegation.rs +++ b/crates/tranquil-db/src/postgres/delegation.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use sqlx::PgPool; use tranquil_db_traits::{ - AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType, + AuditLogEntry, ControllerInfo, DbError, DbScope, DelegatedAccountInfo, DelegationActionType, DelegationGrant, DelegationRepository, }; use tranquil_types::Did; @@ -80,7 +80,7 @@ impl DelegationRepository for PostgresDelegationRepository { &self, delegated_did: &Did, controller_did: &Did, - granted_scopes: &str, + granted_scopes: &DbScope, granted_by: &Did, ) -> Result { let id = sqlx::query_scalar!( @@ -91,7 +91,7 @@ impl DelegationRepository for PostgresDelegationRepository { "#, delegated_did.as_str(), controller_did.as_str(), - granted_scopes, + granted_scopes.as_str(), granted_by.as_str() ) .fetch_one(&self.pool) @@ -128,7 +128,7 @@ impl DelegationRepository for PostgresDelegationRepository { &self, delegated_did: &Did, controller_did: &Did, - new_scopes: &str, + new_scopes: &DbScope, ) -> Result { let result = sqlx::query!( r#" @@ -136,7 +136,7 @@ impl DelegationRepository for PostgresDelegationRepository { SET granted_scopes = $1 WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL "#, - new_scopes, + new_scopes.as_str(), delegated_did.as_str(), controller_did.as_str() ) @@ -170,7 +170,7 @@ impl DelegationRepository for PostgresDelegationRepository { id: r.id, delegated_did: r.delegated_did.into(), controller_did: r.controller_did.into(), - granted_scopes: r.granted_scopes, + granted_scopes: DbScope::from_db(r.granted_scopes), granted_at: r.granted_at, granted_by: r.granted_by.into(), revoked_at: r.revoked_at, @@ -206,7 +206,7 @@ impl DelegationRepository for PostgresDelegationRepository { .map(|r| ControllerInfo { did: r.did.into(), handle: r.handle.into(), - granted_scopes: r.granted_scopes, + granted_scopes: DbScope::from_db(r.granted_scopes), granted_at: r.granted_at, is_active: r.is_active, }) @@ -243,7 +243,7 @@ impl DelegationRepository for PostgresDelegationRepository { .map(|r| DelegatedAccountInfo { did: r.did.into(), handle: r.handle.into(), - granted_scopes: r.granted_scopes, + granted_scopes: DbScope::from_db(r.granted_scopes), granted_at: r.granted_at, }) .collect()) @@ -280,7 +280,7 @@ impl DelegationRepository for PostgresDelegationRepository { .map(|r| ControllerInfo { did: r.did.into(), handle: r.handle.into(), - granted_scopes: r.granted_scopes, + granted_scopes: DbScope::from_db(r.granted_scopes), granted_at: r.granted_at, is_active: r.is_active, }) diff --git a/crates/tranquil-db/src/postgres/infra.rs b/crates/tranquil-db/src/postgres/infra.rs index 05e0636..240050b 100644 --- a/crates/tranquil-db/src/postgres/infra.rs +++ b/crates/tranquil-db/src/postgres/infra.rs @@ -3,8 +3,9 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; use tranquil_db_traits::{ AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, - InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, - NotificationHistoryRow, QueuedComms, ReservedSigningKey, + InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, + InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey, + ValidatedInviteCode, }; use tranquil_types::{CidLink, Did, Handle}; use uuid::Uuid; @@ -182,22 +183,33 @@ impl InfraRepository for PostgresInfraRepository { Ok(result) } - async fn is_invite_code_valid(&self, code: &str) -> Result { - let result = sqlx::query_scalar!( - r#"SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as "valid!" FROM invite_codes WHERE code = $1"#, + async fn validate_invite_code<'a>( + &self, + code: &'a str, + ) -> Result, InviteCodeError> { + let result = sqlx::query!( + r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#, code ) .fetch_optional(&self.pool) .await - .map_err(map_sqlx_error)?; + .map_err(|e| InviteCodeError::DatabaseError(map_sqlx_error(e)))?; - Ok(result.unwrap_or(false)) + match result { + None => Err(InviteCodeError::NotFound), + Some(row) if row.disabled => Err(InviteCodeError::Disabled), + Some(row) if row.available_uses <= 0 => Err(InviteCodeError::ExhaustedUses), + Some(_) => Ok(ValidatedInviteCode::new_validated(code)), + } } - async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> { + async fn decrement_invite_code_uses( + &self, + code: &ValidatedInviteCode<'_>, + ) -> Result<(), DbError> { sqlx::query!( "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", - code + code.code() ) .execute(&self.pool) .await @@ -206,10 +218,14 @@ impl InfraRepository for PostgresInfraRepository { Ok(()) } - async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> { + async fn record_invite_code_use( + &self, + code: &ValidatedInviteCode<'_>, + used_by_user: Uuid, + ) -> Result<(), DbError> { sqlx::query!( "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", - code, + code.code(), used_by_user ) .execute(&self.pool) @@ -245,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository { .map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - disabled: r.disabled.unwrap_or(false), + state: InviteCodeState::from(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: None, @@ -422,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository { .map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - disabled: r.disabled.unwrap_or(false), + state: InviteCodeState::from(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), @@ -445,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository { Ok(result.map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - disabled: r.disabled.unwrap_or(false), + state: InviteCodeState::from(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), @@ -476,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository { InviteCodeInfo { code: r.code, available_uses: r.available_uses, - disabled: r.disabled.unwrap_or(false), + state: InviteCodeState::from(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), @@ -841,9 +857,9 @@ impl InfraRepository for PostgresInfraRepository { r#" SELECT created_at, - channel as "channel: String", - comms_type as "comms_type: String", - status as "status: String", + channel as "channel: CommsChannel", + comms_type as "comms_type: CommsType", + status as "status: CommsStatus", subject, body FROM comms_queue diff --git a/crates/tranquil-db/src/postgres/oauth.rs b/crates/tranquil-db/src/postgres/oauth.rs index 7a71ff4..75cbbdd 100644 --- a/crates/tranquil-db/src/postgres/oauth.rs +++ b/crates/tranquil-db/src/postgres/oauth.rs @@ -4,11 +4,12 @@ use rand::Rng; use sqlx::PgPool; use tranquil_db_traits::{ DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, - ScopePreference, TrustedDeviceRow, TwoFactorChallenge, + ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; use tranquil_oauth::{ - AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData, - TokenData, + AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode, + DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData, + SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId, }; use tranquil_types::{ AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId, @@ -48,7 +49,7 @@ const REFRESH_GRACE_PERIOD_SECS: i64 = 60; #[async_trait] impl OAuthRepository for PostgresOAuthRepository { - async fn create_token(&self, data: &TokenData) -> Result { + async fn create_token(&self, data: &TokenData) -> Result { let client_auth_json = to_json(&data.client_auth)?; let parameters_json = to_json(&data.parameters)?; let row = sqlx::query!( @@ -59,25 +60,25 @@ impl OAuthRepository for PostgresOAuthRepository { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id "#, - data.did, - data.token_id, + data.did.as_str(), + &data.token_id.0, data.created_at, data.updated_at, data.expires_at, data.client_id, client_auth_json, - data.device_id, + data.device_id.as_ref().map(|d| d.0.as_str()), parameters_json, data.details, - data.code, - data.current_refresh_token, + data.code.as_ref().map(|c| c.0.as_str()), + data.current_refresh_token.as_ref().map(|r| r.0.as_str()), data.scope, - data.controller_did, + data.controller_did.as_ref().map(|d| d.as_str()), ) .fetch_one(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(row.id) + Ok(TokenFamilyId::new(row.id)) } async fn get_token_by_id(&self, token_id: &TokenId) -> Result, DbError> { @@ -95,20 +96,27 @@ impl OAuthRepository for PostgresOAuthRepository { .map_err(map_sqlx_error)?; match row { Some(r) => Ok(Some(TokenData { - did: r.did, - token_id: r.token_id, + did: r + .did + .parse() + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, + token_id: OAuthTokenId(r.token_id), created_at: r.created_at, updated_at: r.updated_at, expires_at: r.expires_at, client_id: r.client_id, client_auth: from_json(r.client_auth)?, - device_id: r.device_id, + device_id: r.device_id.map(OAuthDeviceId), parameters: from_json(r.parameters)?, details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, + code: r.code.map(OAuthCode), + current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), scope: r.scope, - controller_did: r.controller_did, + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, })), None => Ok(None), } @@ -117,7 +125,7 @@ impl OAuthRepository for PostgresOAuthRepository { async fn get_token_by_refresh_token( &self, refresh_token: &RefreshToken, - ) -> Result, DbError> { + ) -> Result, DbError> { let row = sqlx::query!( r#" SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, @@ -132,22 +140,29 @@ impl OAuthRepository for PostgresOAuthRepository { .map_err(map_sqlx_error)?; match row { Some(r) => Ok(Some(( - r.id, + TokenFamilyId::new(r.id), TokenData { - did: r.did, - token_id: r.token_id, + did: r + .did + .parse() + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, + token_id: OAuthTokenId(r.token_id), created_at: r.created_at, updated_at: r.updated_at, expires_at: r.expires_at, client_id: r.client_id, client_auth: from_json(r.client_auth)?, - device_id: r.device_id, + device_id: r.device_id.map(OAuthDeviceId), parameters: from_json(r.parameters)?, details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, + code: r.code.map(OAuthCode), + current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), scope: r.scope, - controller_did: r.controller_did, + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, }, ))), None => Ok(None), @@ -157,7 +172,7 @@ impl OAuthRepository for PostgresOAuthRepository { async fn get_token_by_previous_refresh_token( &self, refresh_token: &RefreshToken, - ) -> Result, DbError> { + ) -> Result, DbError> { let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); let row = sqlx::query!( r#" @@ -174,22 +189,29 @@ impl OAuthRepository for PostgresOAuthRepository { .map_err(map_sqlx_error)?; match row { Some(r) => Ok(Some(( - r.id, + TokenFamilyId::new(r.id), TokenData { - did: r.did, - token_id: r.token_id, + did: r + .did + .parse() + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, + token_id: OAuthTokenId(r.token_id), created_at: r.created_at, updated_at: r.updated_at, expires_at: r.expires_at, client_id: r.client_id, client_auth: from_json(r.client_auth)?, - device_id: r.device_id, + device_id: r.device_id.map(OAuthDeviceId), parameters: from_json(r.parameters)?, details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, + code: r.code.map(OAuthCode), + current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), scope: r.scope, - controller_did: r.controller_did, + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, }, ))), None => Ok(None), @@ -198,7 +220,7 @@ impl OAuthRepository for PostgresOAuthRepository { async fn rotate_token( &self, - old_db_id: i32, + old_db_id: TokenFamilyId, new_refresh_token: &RefreshToken, new_expires_at: DateTime, ) -> Result<(), DbError> { @@ -207,7 +229,7 @@ impl OAuthRepository for PostgresOAuthRepository { r#" SELECT current_refresh_token FROM oauth_token WHERE id = $1 "#, - old_db_id + old_db_id.as_i32() ) .fetch_one(&mut *tx) .await @@ -219,7 +241,7 @@ impl OAuthRepository for PostgresOAuthRepository { VALUES ($1, $2) "#, old_rt, - old_db_id + old_db_id.as_i32() ) .execute(&mut *tx) .await @@ -232,7 +254,7 @@ impl OAuthRepository for PostgresOAuthRepository { previous_refresh_token = $4, rotated_at = NOW() WHERE id = $1 "#, - old_db_id, + old_db_id.as_i32(), new_refresh_token.as_str(), new_expires_at, old_refresh @@ -247,7 +269,7 @@ impl OAuthRepository for PostgresOAuthRepository { async fn check_refresh_token_used( &self, refresh_token: &RefreshToken, - ) -> Result, DbError> { + ) -> Result, DbError> { let row = sqlx::query_scalar!( r#" SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 @@ -257,7 +279,7 @@ impl OAuthRepository for PostgresOAuthRepository { .fetch_optional(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(row) + Ok(row.map(TokenFamilyId::new)) } async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> { @@ -273,12 +295,12 @@ impl OAuthRepository for PostgresOAuthRepository { Ok(()) } - async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> { + async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError> { sqlx::query!( r#" DELETE FROM oauth_token WHERE id = $1 "#, - db_id + db_id.as_i32() ) .execute(&self.pool) .await @@ -302,20 +324,27 @@ impl OAuthRepository for PostgresOAuthRepository { rows.into_iter() .map(|r| { Ok(TokenData { - did: r.did, - token_id: r.token_id, + did: r + .did + .parse() + .map_err(|_| DbError::Other("Invalid DID in token".into()))?, + token_id: OAuthTokenId(r.token_id), created_at: r.created_at, updated_at: r.updated_at, expires_at: r.expires_at, client_id: r.client_id, client_auth: from_json(r.client_auth)?, - device_id: r.device_id, + device_id: r.device_id.map(OAuthDeviceId), parameters: from_json(r.parameters)?, details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, + code: r.code.map(OAuthCode), + current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken), scope: r.scope, - controller_did: r.controller_did, + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID".into()))?, }) }) .collect() @@ -407,13 +436,13 @@ impl OAuthRepository for PostgresOAuthRepository { VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, request_id.as_str(), - data.did, - data.device_id, + data.did.as_ref().map(|d| d.as_str()), + data.device_id.as_ref().map(|d| d.0.as_str()), data.client_id, client_auth_json, parameters_json, data.expires_at, - data.code, + data.code.as_ref().map(|c| c.0.as_str()), ) .execute(&self.pool) .await @@ -448,10 +477,18 @@ impl OAuthRepository for PostgresOAuthRepository { client_auth, parameters, expires_at: r.expires_at, - did: r.did, - device_id: r.device_id, - code: r.code, - controller_did: r.controller_did, + did: r + .did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, + device_id: r.device_id.map(OAuthDeviceId), + code: r.code.map(OAuthCode), + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, })) } None => Ok(None), @@ -534,10 +571,18 @@ impl OAuthRepository for PostgresOAuthRepository { client_auth, parameters, expires_at: r.expires_at, - did: r.did, - device_id: r.device_id, - code: r.code, - controller_did: r.controller_did, + did: r + .did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid DID in DB".into()))?, + device_id: r.device_id.map(OAuthDeviceId), + code: r.code.map(OAuthCode), + controller_did: r + .controller_did + .map(|s| s.parse()) + .transpose() + .map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?, })) } None => Ok(None), @@ -655,7 +700,7 @@ impl OAuthRepository for PostgresOAuthRepository { VALUES ($1, $2, $3, $4, $5) "#, device_id.as_str(), - data.session_id, + &data.session_id.0, data.user_agent, data.ip_address, data.last_seen_at, @@ -679,7 +724,7 @@ impl OAuthRepository for PostgresOAuthRepository { .await .map_err(map_sqlx_error)?; Ok(row.map(|r| DeviceData { - session_id: r.session_id, + session_id: OAuthSessionId(r.session_id), user_agent: r.user_agent, ip_address: r.ip_address, last_seen_at: r.last_seen_at, @@ -1207,7 +1252,7 @@ impl OAuthRepository for PostgresOAuthRepository { Ok(rows .into_iter() .map(|r| OAuthSessionListItem { - id: r.id, + id: TokenFamilyId::new(r.id), token_id: TokenId::from(r.token_id), created_at: r.created_at, expires_at: r.expires_at, @@ -1216,10 +1261,14 @@ impl OAuthRepository for PostgresOAuthRepository { .collect()) } - async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result { + async fn delete_session_by_id( + &self, + session_id: TokenFamilyId, + did: &Did, + ) -> Result { let result = sqlx::query!( "DELETE FROM oauth_token WHERE id = $1 AND did = $2", - session_id, + session_id.as_i32(), did.as_str() ) .execute(&self.pool) diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs index 778efad..14d6644 100644 --- a/crates/tranquil-db/src/postgres/repo.rs +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use tranquil_db_traits::{ - BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, ImportBlock, - ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, RepoInfo, - RepoListItem, RepoRepository, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, - UserWithoutBlocks, + AccountStatus, BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, + ImportBlock, ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, + RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, SequenceNumber, + SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, }; use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; use uuid::Uuid; @@ -21,7 +21,7 @@ struct SequencedEventRow { seq: i64, did: String, created_at: DateTime, - event_type: String, + event_type: RepoEventType, commit_cid: Option, prev_cid: Option, prev_data_cid: Option, @@ -627,7 +627,7 @@ impl RepoRepository for PostgresRepoRepository { Ok(rows.into_iter().map(|(cid,)| cid).collect()) } - async fn insert_commit_event(&self, data: &CommitEventData) -> Result { + async fn insert_commit_event(&self, data: &CommitEventData) -> Result { let seq = sqlx::query_scalar!( r#" INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) @@ -635,7 +635,7 @@ impl RepoRepository for PostgresRepoRepository { RETURNING seq "#, data.did.as_str(), - data.event_type, + data.event_type.as_str(), data.commit_cid.as_ref().map(|c| c.as_str()), data.prev_cid.as_ref().map(|c| c.as_str()), data.ops, @@ -648,14 +648,14 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } async fn insert_identity_event( &self, did: &Did, handle: Option<&Handle>, - ) -> Result { + ) -> Result { let handle_str = handle.map(|h| h.as_str()); let seq = sqlx::query_scalar!( r#" @@ -675,15 +675,16 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } async fn insert_account_event( &self, did: &Did, - active: bool, - status: Option<&str>, - ) -> Result { + status: AccountStatus, + ) -> Result { + let active = status.is_active(); + let status_str = status.for_firehose(); let seq = sqlx::query_scalar!( r#" INSERT INTO repo_seq (did, event_type, active, status) @@ -692,7 +693,7 @@ impl RepoRepository for PostgresRepoRepository { "#, did.as_str(), active, - status + status_str ) .fetch_one(&self.pool) .await @@ -703,7 +704,7 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } async fn insert_sync_event( @@ -711,7 +712,7 @@ impl RepoRepository for PostgresRepoRepository { did: &Did, commit_cid: &CidLink, rev: Option<&str>, - ) -> Result { + ) -> Result { let seq = sqlx::query_scalar!( r#" INSERT INTO repo_seq (did, event_type, commit_cid, rev) @@ -731,7 +732,7 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } async fn insert_genesis_commit_event( @@ -740,7 +741,7 @@ impl RepoRepository for PostgresRepoRepository { commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, - ) -> Result { + ) -> Result { let ops = serde_json::json!([]); let blobs: Vec = vec![]; let blocks_cids: Vec = vec![mst_root_cid.to_string(), commit_cid.to_string()]; @@ -769,18 +770,18 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } async fn update_seq_blocks_cids( &self, - seq: i64, + seq: SequenceNumber, blocks_cids: &[String], ) -> Result<(), DbError> { sqlx::query!( "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", blocks_cids, - seq + seq.as_i64() ) .execute(&self.pool) .await @@ -789,11 +790,15 @@ impl RepoRepository for PostgresRepoRepository { Ok(()) } - async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> { + async fn delete_sequences_except( + &self, + did: &Did, + keep_seq: SequenceNumber, + ) -> Result<(), DbError> { sqlx::query!( "DELETE FROM repo_seq WHERE did = $1 AND seq != $2", did.as_str(), - keep_seq + keep_seq.as_i64() ) .execute(&self.pool) .await @@ -802,16 +807,19 @@ impl RepoRepository for PostgresRepoRepository { Ok(()) } - async fn get_max_seq(&self) -> Result { + async fn get_max_seq(&self) -> Result { let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) .fetch_one(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.into()) } - async fn get_min_seq_since(&self, since: DateTime) -> Result, DbError> { + async fn get_min_seq_since( + &self, + since: DateTime, + ) -> Result, DbError> { let seq = sqlx::query_scalar!( "SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1", since @@ -820,7 +828,7 @@ impl RepoRepository for PostgresRepoRepository { .await .map_err(map_sqlx_error)?; - Ok(seq) + Ok(seq.map(SequenceNumber::from)) } async fn get_account_with_repo(&self, did: &Did) -> Result, DbError> { @@ -846,36 +854,43 @@ impl RepoRepository for PostgresRepoRepository { async fn get_events_since_seq( &self, - since_seq: i64, + since_seq: SequenceNumber, limit: Option, ) -> Result, DbError> { - let map_row = |r: SequencedEventRow| SequencedEvent { - seq: r.seq, - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status: r.status, - rev: r.rev, + let map_row = |r: SequencedEventRow| { + let status = r + .status + .as_deref() + .and_then(AccountStatus::parse) + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); + SequencedEvent { + seq: r.seq.into(), + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status, + rev: r.rev, + } }; match limit { Some(lim) => { let rows = sqlx::query_as!( SequencedEventRow, - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC LIMIT $2"#, - since_seq, + since_seq.as_i64(), lim ) .fetch_all(&self.pool) @@ -886,12 +901,12 @@ impl RepoRepository for PostgresRepoRepository { None => { let rows = sqlx::query_as!( SequencedEventRow, - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC"#, - since_seq + since_seq.as_i64() ) .fetch_all(&self.pool) .await @@ -903,25 +918,71 @@ impl RepoRepository for PostgresRepoRepository { async fn get_events_in_seq_range( &self, - start_seq: i64, - end_seq: i64, + start_seq: SequenceNumber, + end_seq: SequenceNumber, ) -> Result, DbError> { let rows = sqlx::query!( - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 AND seq < $2 ORDER BY seq ASC"#, - start_seq, - end_seq + start_seq.as_i64(), + end_seq.as_i64() ) .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; Ok(rows .into_iter() - .map(|r| SequencedEvent { - seq: r.seq, + .map(|r| { + let status = r + .status + .as_deref() + .and_then(AccountStatus::parse) + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); + SequencedEvent { + seq: r.seq.into(), + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status, + rev: r.rev, + } + }) + .collect()) + } + + async fn get_event_by_seq( + &self, + seq: SequenceNumber, + ) -> Result, DbError> { + let row = sqlx::query!( + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, + ops, blobs, blocks_cids, handle, active, status, rev + FROM repo_seq + WHERE seq = $1"#, + seq.as_i64() + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(row.map(|r| { + let status = r + .status + .as_deref() + .and_then(AccountStatus::parse) + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); + SequencedEvent { + seq: r.seq.into(), did: Did::from(r.did), created_at: r.created_at, event_type: r.event_type, @@ -933,54 +994,25 @@ impl RepoRepository for PostgresRepoRepository { blocks_cids: r.blocks_cids, handle: r.handle.map(Handle::from), active: r.active, - status: r.status, + status, rev: r.rev, - }) - .collect()) - } - - async fn get_event_by_seq(&self, seq: i64) -> Result, DbError> { - let row = sqlx::query!( - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev - FROM repo_seq - WHERE seq = $1"#, - seq - ) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(row.map(|r| SequencedEvent { - seq: r.seq, - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status: r.status, - rev: r.rev, + } })) } async fn get_events_since_cursor( &self, - cursor: i64, + cursor: SequenceNumber, limit: i64, ) -> Result, DbError> { let rows = sqlx::query!( - r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid, + r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC LIMIT $2"#, - cursor, + cursor.as_i64(), limit ) .fetch_all(&self.pool) @@ -988,21 +1020,28 @@ impl RepoRepository for PostgresRepoRepository { .map_err(map_sqlx_error)?; Ok(rows .into_iter() - .map(|r| SequencedEvent { - seq: r.seq, - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status: r.status, - rev: r.rev, + .map(|r| { + let status = r + .status + .as_deref() + .and_then(AccountStatus::parse) + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); + SequencedEvent { + seq: r.seq.into(), + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks_cids: r.blocks_cids, + handle: r.handle.map(Handle::from), + active: r.active, + status, + rev: r.rev, + } }) .collect()) } @@ -1079,8 +1118,8 @@ impl RepoRepository for PostgresRepoRepository { Ok(cid.map(CidLink::from)) } - async fn notify_update(&self, seq: i64) -> Result<(), DbError> { - sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq)) + async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> { + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64())) .execute(&self.pool) .await .map_err(map_sqlx_error)?; @@ -1329,7 +1368,7 @@ impl RepoRepository for PostgresRepoRepository { "#, ) .bind(&event.did) - .bind(&event.event_type) + .bind(event.event_type.as_str()) .bind(&event.commit_cid) .bind(&event.prev_cid) .bind(&event.ops) @@ -1375,7 +1414,7 @@ impl RepoRepository for PostgresRepoRepository { Ok(rows .into_iter() .map(|r| BrokenGenesisCommit { - seq: r.seq, + seq: r.seq.into(), did: Did::from(r.did), commit_cid: r.commit_cid.map(CidLink::from), }) diff --git a/crates/tranquil-db/src/postgres/session.rs b/crates/tranquil-db/src/postgres/session.rs index 72c8069..3793f5f 100644 --- a/crates/tranquil-db/src/postgres/session.rs +++ b/crates/tranquil-db/src/postgres/session.rs @@ -2,9 +2,9 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use tranquil_db_traits::{ - AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh, - SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, - SessionTokenCreate, + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType, + RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, + SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, }; use tranquil_types::Did; use uuid::Uuid; @@ -23,7 +23,7 @@ impl PostgresSessionRepository { #[async_trait] impl SessionRepository for PostgresSessionRepository { - async fn create_session(&self, data: &SessionTokenCreate) -> Result { + async fn create_session(&self, data: &SessionTokenCreate) -> Result { let row = sqlx::query!( r#" INSERT INTO session_tokens @@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository { data.refresh_jti, data.access_expires_at, data.refresh_expires_at, - data.legacy_login, + bool::from(data.login_type), data.mfa_verified, data.scope, data.controller_did.as_ref().map(|d| d.as_str()), @@ -47,7 +47,7 @@ impl SessionRepository for PostgresSessionRepository { .await .map_err(map_sqlx_error)?; - Ok(row.id) + Ok(SessionId::new(row.id)) } async fn get_session_by_access_jti( @@ -69,13 +69,13 @@ impl SessionRepository for PostgresSessionRepository { .map_err(map_sqlx_error)?; Ok(row.map(|r| SessionToken { - id: r.id, + id: SessionId::new(r.id), did: Did::from(r.did), access_jti: r.access_jti, refresh_jti: r.refresh_jti, access_expires_at: r.access_expires_at, refresh_expires_at: r.refresh_expires_at, - legacy_login: r.legacy_login, + login_type: LoginType::from(r.legacy_login), mfa_verified: r.mfa_verified, scope: r.scope, controller_did: r.controller_did.map(Did::from), @@ -104,7 +104,7 @@ impl SessionRepository for PostgresSessionRepository { .map_err(map_sqlx_error)?; Ok(row.map(|r| SessionForRefresh { - id: r.id, + id: SessionId::new(r.id), did: Did::from(r.did), scope: r.scope, controller_did: r.controller_did.map(Did::from), @@ -115,7 +115,7 @@ impl SessionRepository for PostgresSessionRepository { async fn update_session_tokens( &self, - session_id: i32, + session_id: SessionId, new_access_jti: &str, new_refresh_jti: &str, new_access_expires_at: DateTime, @@ -132,7 +132,7 @@ impl SessionRepository for PostgresSessionRepository { new_refresh_jti, new_access_expires_at, new_refresh_expires_at, - session_id + session_id.as_i32() ) .execute(&self.pool) .await @@ -153,11 +153,14 @@ impl SessionRepository for PostgresSessionRepository { Ok(result.rows_affected()) } - async fn delete_session_by_id(&self, session_id: i32) -> Result { - let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; + async fn delete_session_by_id(&self, session_id: SessionId) -> Result { + let result = sqlx::query!( + "DELETE FROM session_tokens WHERE id = $1", + session_id.as_i32() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; Ok(result.rows_affected()) } @@ -205,7 +208,7 @@ impl SessionRepository for PostgresSessionRepository { Ok(rows .into_iter() .map(|r| SessionListItem { - id: r.id, + id: SessionId::new(r.id), access_jti: r.access_jti, created_at: r.created_at, refresh_expires_at: r.refresh_expires_at, @@ -215,12 +218,12 @@ impl SessionRepository for PostgresSessionRepository { async fn get_session_access_jti_by_id( &self, - session_id: i32, + session_id: SessionId, did: &Did, ) -> Result, DbError> { let row = sqlx::query_scalar!( "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", - session_id, + session_id.as_i32(), did.as_str() ) .fetch_optional(&self.pool) @@ -264,7 +267,10 @@ impl SessionRepository for PostgresSessionRepository { Ok(rows) } - async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result, DbError> { + async fn check_refresh_token_used( + &self, + refresh_jti: &str, + ) -> Result, DbError> { let row = sqlx::query_scalar!( "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", refresh_jti @@ -273,13 +279,13 @@ impl SessionRepository for PostgresSessionRepository { .await .map_err(map_sqlx_error)?; - Ok(row) + Ok(row.map(SessionId::new)) } async fn mark_refresh_token_used( &self, refresh_jti: &str, - session_id: i32, + session_id: SessionId, ) -> Result { let result = sqlx::query!( r#" @@ -288,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository { ON CONFLICT (refresh_jti) DO NOTHING "#, refresh_jti, - session_id + session_id.as_i32() ) .execute(&self.pool) .await @@ -319,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privileged: r.privileged, + privilege: AppPasswordPrivilege::from(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), }) @@ -352,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privileged: r.privileged, + privilege: AppPasswordPrivilege::from(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), }) @@ -383,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privileged: r.privileged, + privilege: AppPasswordPrivilege::from(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), })) @@ -399,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository { data.user_id, data.name, data.password_hash, - data.privileged, + bool::from(data.privilege), data.scopes, data.created_by_controller_did.as_ref().map(|d| d.as_str()) ) @@ -480,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository { .map_err(map_sqlx_error)?; Ok(row.map(|r| SessionMfaStatus { - legacy_login: r.legacy_login, + login_type: LoginType::from(r.legacy_login), mfa_verified: r.mfa_verified, last_reauth_at: r.last_reauth_at, })) @@ -535,16 +541,19 @@ impl SessionRepository for PostgresSessionRepository { let result = sqlx::query!( "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", data.old_refresh_jti, - data.session_id + data.session_id.as_i32() ) .execute(&mut *tx) .await .map_err(map_sqlx_error)?; if result.rows_affected() == 0 { - let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id) - .execute(&mut *tx) - .await; + let _ = sqlx::query!( + "DELETE FROM session_tokens WHERE id = $1", + data.session_id.as_i32() + ) + .execute(&mut *tx) + .await; tx.commit().await.map_err(map_sqlx_error)?; return Ok(RefreshSessionResult::ConcurrentRefresh); } @@ -555,7 +564,7 @@ impl SessionRepository for PostgresSessionRepository { data.new_refresh_jti, data.new_access_expires_at, data.new_refresh_expires_at, - data.session_id + data.session_id.as_i32() ) .execute(&mut *tx) .await diff --git a/crates/tranquil-db/src/postgres/sso.rs b/crates/tranquil-db/src/postgres/sso.rs index a662cbc..9402fd3 100644 --- a/crates/tranquil-db/src/postgres/sso.rs +++ b/crates/tranquil-db/src/postgres/sso.rs @@ -2,7 +2,8 @@ use async_trait::async_trait; use chrono::Utc; use sqlx::PgPool; use tranquil_db_traits::{ - DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, + DbError, ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, + SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, }; use tranquil_types::Did; use uuid::Uuid; @@ -69,11 +70,11 @@ impl SsoRepository for PostgresSsoRepository { Ok(row.map(|r| ExternalIdentity { id: r.id, - did: Did::new_unchecked(&r.did), + did: unsafe { Did::new_unchecked(&r.did) }, provider: r.provider, - provider_user_id: r.provider_user_id, - provider_username: r.provider_username, - provider_email: r.provider_email, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), created_at: r.created_at, updated_at: r.updated_at, last_login_at: r.last_login_at, @@ -102,11 +103,11 @@ impl SsoRepository for PostgresSsoRepository { .into_iter() .map(|r| ExternalIdentity { id: r.id, - did: Did::new_unchecked(&r.did), + did: unsafe { Did::new_unchecked(&r.did) }, provider: r.provider, - provider_user_id: r.provider_user_id, - provider_username: r.provider_username, - provider_email: r.provider_email, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), created_at: r.created_at, updated_at: r.updated_at, last_login_at: r.last_login_at, @@ -161,7 +162,7 @@ impl SsoRepository for PostgresSsoRepository { state: &str, request_uri: &str, provider: SsoProviderType, - action: &str, + action: SsoAction, nonce: Option<&str>, code_verifier: Option<&str>, did: Option<&Did>, @@ -174,7 +175,7 @@ impl SsoRepository for PostgresSsoRepository { state, request_uri, provider as SsoProviderType, - action, + action.as_str(), nonce, code_verifier, did.map(|d| d.as_str()), @@ -200,17 +201,21 @@ impl SsoRepository for PostgresSsoRepository { .await .map_err(map_sqlx_error)?; - Ok(row.map(|r| SsoAuthState { - state: r.state, - request_uri: r.request_uri, - provider: r.provider, - action: r.action, - nonce: r.nonce, - code_verifier: r.code_verifier, - did: r.did.map(|d| Did::new_unchecked(&d)), - created_at: r.created_at, - expires_at: r.expires_at, - })) + row.map(|r| { + let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?; + Ok(SsoAuthState { + state: r.state, + request_uri: r.request_uri, + provider: r.provider, + action, + nonce: r.nonce, + code_verifier: r.code_verifier, + did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }), + created_at: r.created_at, + expires_at: r.expires_at, + }) + }) + .transpose() } async fn cleanup_expired_sso_auth_states(&self) -> Result { @@ -280,9 +285,9 @@ impl SsoRepository for PostgresSsoRepository { token: r.token, request_uri: r.request_uri, provider: r.provider, - provider_user_id: r.provider_user_id, - provider_username: r.provider_username, - provider_email: r.provider_email, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), provider_email_verified: r.provider_email_verified, created_at: r.created_at, expires_at: r.expires_at, @@ -311,9 +316,9 @@ impl SsoRepository for PostgresSsoRepository { token: r.token, request_uri: r.request_uri, provider: r.provider, - provider_user_id: r.provider_user_id, - provider_username: r.provider_username, - provider_email: r.provider_email, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), provider_email_verified: r.provider_email_verified, created_at: r.created_at, expires_at: r.expires_at, diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index 2f33251..2115489 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -5,15 +5,16 @@ use tranquil_types::{Did, Handle}; use uuid::Uuid; use tranquil_db_traits::{ - AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs, - OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey, - TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, - UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, - UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, - UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, - UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, - UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, - UserStatus, UserVerificationInfo, UserWithKey, + AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError, + DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType, + StoredBackupCode, StoredPasskey, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, + UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, + UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, + UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, + UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, + UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, + UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, + UserVerificationInfo, UserWithKey, }; pub struct PostgresUserRepository { @@ -280,10 +281,12 @@ impl UserRepository for PostgresUserRepository { password_hash: r.password_hash, deactivated_at: r.deactivated_at, takedown_ref: r.takedown_ref, - email_verified: r.email_verified, - discord_verified: r.discord_verified, - telegram_verified: r.telegram_verified, - signal_verified: r.signal_verified, + channel_verification: ChannelVerificationStatus::new( + r.email_verified, + r.discord_verified, + r.telegram_verified, + r.signal_verified, + ), })) } @@ -308,7 +311,7 @@ impl UserRepository for PostgresUserRepository { async fn get_comms_prefs(&self, user_id: Uuid) -> Result, DbError> { let row = sqlx::query!( - r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale FROM users WHERE id = $1"#, user_id ) @@ -601,7 +604,7 @@ impl UserRepository for PostgresUserRepository { let row = sqlx::query!( r#"SELECT email, - preferred_comms_channel::text as "preferred_channel!", + preferred_comms_channel as "preferred_channel!: CommsChannel", discord_id, discord_verified, telegram_username, @@ -647,13 +650,13 @@ impl UserRepository for PostgresUserRepository { async fn update_preferred_comms_channel( &self, did: &Did, - channel: &str, + channel: CommsChannel, ) -> Result<(), DbError> { - sqlx::query( - "UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2", + sqlx::query!( + "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2", + channel as CommsChannel, + did.as_str() ) - .bind(channel) - .bind(did.as_str()) .execute(&self.pool) .await .map_err(map_sqlx_error)?; @@ -709,10 +712,12 @@ impl UserRepository for PostgresUserRepository { id: r.id, handle: Handle::from(r.handle), email: r.email, - email_verified: r.email_verified, - discord_verified: r.discord_verified, - telegram_verified: r.telegram_verified, - signal_verified: r.signal_verified, + channel_verification: ChannelVerificationStatus::new( + r.email_verified, + r.discord_verified, + r.telegram_verified, + r.signal_verified, + ), })) } @@ -1065,6 +1070,12 @@ impl UserRepository for PostgresUserRepository { })) } + async fn get_totp_record_state(&self, did: &Did) -> Result, DbError> { + self.get_totp_record(did) + .await + .map(|opt| opt.map(TotpRecordState::from)) + } + async fn upsert_totp_secret( &self, did: &Did, @@ -1300,7 +1311,7 @@ impl UserRepository for PostgresUserRepository { preferred_comms_channel as "preferred_comms_channel!: CommsChannel", deactivated_at, takedown_ref, email_verified, discord_verified, telegram_verified, signal_verified, - account_type::text as "account_type!" + account_type as "account_type!: AccountType" FROM users WHERE handle = $1 OR email = $1 "#, @@ -1320,10 +1331,12 @@ impl UserRepository for PostgresUserRepository { preferred_comms_channel: row.preferred_comms_channel, deactivated_at: row.deactivated_at, takedown_ref: row.takedown_ref, - email_verified: row.email_verified, - discord_verified: row.discord_verified, - telegram_verified: row.telegram_verified, - signal_verified: row.signal_verified, + channel_verification: ChannelVerificationStatus::new( + row.email_verified, + row.discord_verified, + row.telegram_verified, + row.signal_verified, + ), account_type: row.account_type, }) }) @@ -1348,10 +1361,12 @@ impl UserRepository for PostgresUserRepository { id: row.id, two_factor_enabled: row.two_factor_enabled, preferred_comms_channel: row.preferred_comms_channel, - email_verified: row.email_verified, - discord_verified: row.discord_verified, - telegram_verified: row.telegram_verified, - signal_verified: row.signal_verified, + channel_verification: ChannelVerificationStatus::new( + row.email_verified, + row.discord_verified, + row.telegram_verified, + row.signal_verified, + ), }) }) } @@ -1376,15 +1391,17 @@ impl UserRepository for PostgresUserRepository { opt.map(|row| UserSessionInfo { handle: Handle::from(row.handle), email: row.email, - email_verified: row.email_verified, is_admin: row.is_admin, deactivated_at: row.deactivated_at, takedown_ref: row.takedown_ref, preferred_locale: row.preferred_locale, preferred_comms_channel: row.preferred_comms_channel, - discord_verified: row.discord_verified, - telegram_verified: row.telegram_verified, - signal_verified: row.signal_verified, + channel_verification: ChannelVerificationStatus::new( + row.email_verified, + row.discord_verified, + row.telegram_verified, + row.signal_verified, + ), migrated_to_pds: row.migrated_to_pds, migrated_at: row.migrated_at, }) @@ -1469,10 +1486,12 @@ impl UserRepository for PostgresUserRepository { email: row.email, deactivated_at: row.deactivated_at, takedown_ref: row.takedown_ref, - email_verified: row.email_verified, - discord_verified: row.discord_verified, - telegram_verified: row.telegram_verified, - signal_verified: row.signal_verified, + channel_verification: ChannelVerificationStatus::new( + row.email_verified, + row.discord_verified, + row.telegram_verified, + row.signal_verified, + ), allow_legacy_login: row.allow_legacy_login, migrated_to_pds: row.migrated_to_pds, preferred_comms_channel: row.preferred_comms_channel, @@ -1543,10 +1562,12 @@ impl UserRepository for PostgresUserRepository { discord_id: row.discord_id, telegram_username: row.telegram_username, signal_number: row.signal_number, - email_verified: row.email_verified, - discord_verified: row.discord_verified, - telegram_verified: row.telegram_verified, - signal_verified: row.signal_verified, + channel_verification: ChannelVerificationStatus::new( + row.email_verified, + row.discord_verified, + row.telegram_verified, + row.signal_verified, + ), }) }) } diff --git a/crates/tranquil-oauth/src/lib.rs b/crates/tranquil-oauth/src/lib.rs index 493252c..9785fbf 100644 --- a/crates/tranquil-oauth/src/lib.rs +++ b/crates/tranquil-oauth/src/lib.rs @@ -10,8 +10,10 @@ pub use dpop::{ }; pub use error::OAuthError; pub use types::{ - AuthFlowState, AuthorizationRequestParameters, AuthorizationServerMetadata, - AuthorizedClientData, ClientAuth, Code, DPoPClaims, DeviceData, DeviceId, JwkPublicKey, Jwks, - OAuthClientMetadata, ParResponse, ProtectedResourceMetadata, RefreshToken, RefreshTokenState, - RequestData, RequestId, SessionId, TokenData, TokenId, TokenRequest, TokenResponse, + AuthFlow, AuthFlowWithUser, AuthorizationRequestParameters, AuthorizationServerMetadata, + AuthorizedClientData, ClientAuth, Code, CodeChallengeMethod, DPoPClaims, DeviceData, DeviceId, + FlowAuthenticated, FlowAuthorized, FlowExpired, FlowNotAuthenticated, FlowNotAuthorized, + FlowPending, JwkPublicKey, Jwks, OAuthClientMetadata, ParResponse, Prompt, + ProtectedResourceMetadata, RefreshToken, RefreshTokenState, RequestData, RequestId, + ResponseMode, ResponseType, SessionId, TokenData, TokenId, TokenRequest, TokenResponse, }; diff --git a/crates/tranquil-oauth/src/types.rs b/crates/tranquil-oauth/src/types.rs index 2d36ae5..792e451 100644 --- a/crates/tranquil-oauth/src/types.rs +++ b/crates/tranquil-oauth/src/types.rs @@ -1,23 +1,36 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +use tranquil_types::Did; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct RequestId(pub String); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct TokenId(pub String); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct DeviceId(pub String); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct SessionId(pub String); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct Code(pub String); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] pub struct RefreshToken(pub String); impl RequestId { @@ -82,19 +95,76 @@ pub enum ClientAuth { PrivateKeyJwt { client_assertion: String }, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseType { + #[default] + Code, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CodeChallengeMethod { + #[default] + #[serde(rename = "S256")] + S256, + #[serde(rename = "plain")] + Plain, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMode { + #[default] + Query, + Fragment, + FormPost, +} + +impl ResponseMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Query => "query", + Self::Fragment => "fragment", + Self::FormPost => "form_post", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Prompt { + None, + Login, + Consent, + SelectAccount, + Create, +} + +impl Prompt { + pub fn as_str(&self) -> &'static str { + match self { + Self::None => "none", + Self::Login => "login", + Self::Consent => "consent", + Self::SelectAccount => "select_account", + Self::Create => "create", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorizationRequestParameters { - pub response_type: String, + pub response_type: ResponseType, pub client_id: String, pub redirect_uri: String, pub scope: Option, pub state: Option, pub code_challenge: String, - pub code_challenge_method: String, - pub response_mode: Option, + pub code_challenge_method: CodeChallengeMethod, + pub response_mode: Option, pub login_hint: Option, pub dpop_jkt: Option, - pub prompt: Option, + pub prompt: Option, #[serde(flatten)] pub extra: Option, } @@ -105,15 +175,15 @@ pub struct RequestData { pub client_auth: Option, pub parameters: AuthorizationRequestParameters, pub expires_at: DateTime, - pub did: Option, - pub device_id: Option, - pub code: Option, - pub controller_did: Option, + pub did: Option, + pub device_id: Option, + pub code: Option, + pub controller_did: Option, } #[derive(Debug, Clone)] pub struct DeviceData { - pub session_id: String, + pub session_id: SessionId, pub user_agent: Option, pub ip_address: String, pub last_seen_at: DateTime, @@ -121,20 +191,20 @@ pub struct DeviceData { #[derive(Debug, Clone)] pub struct TokenData { - pub did: String, - pub token_id: String, + pub did: Did, + pub token_id: TokenId, pub created_at: DateTime, pub updated_at: DateTime, pub expires_at: DateTime, pub client_id: String, pub client_auth: ClientAuth, - pub device_id: Option, + pub device_id: Option, pub parameters: AuthorizationRequestParameters, pub details: Option, - pub code: Option, - pub current_refresh_token: Option, + pub code: Option, + pub current_refresh_token: Option, pub scope: Option, - pub controller_did: Option, + pub controller_did: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -247,99 +317,144 @@ pub struct Jwks { pub keys: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthFlowState { - Pending, - Authenticated { - did: String, - device_id: Option, - }, - Authorized { - did: String, - device_id: Option, - code: String, - }, - Expired, +#[derive(Debug, Clone)] +pub struct FlowPending { + pub parameters: AuthorizationRequestParameters, + pub client_id: String, + pub client_auth: Option, + pub expires_at: DateTime, + pub controller_did: Option, } -impl AuthFlowState { - pub fn from_request_data(data: &RequestData) -> Self { +#[derive(Debug, Clone)] +pub struct FlowAuthenticated { + pub parameters: AuthorizationRequestParameters, + pub client_id: String, + pub client_auth: Option, + pub expires_at: DateTime, + pub did: Did, + pub device_id: Option, + pub controller_did: Option, +} + +#[derive(Debug, Clone)] +pub struct FlowAuthorized { + pub parameters: AuthorizationRequestParameters, + pub client_id: String, + pub client_auth: Option, + pub expires_at: DateTime, + pub did: Did, + pub device_id: Option, + pub code: Code, + pub controller_did: Option, +} + +#[derive(Debug)] +pub struct FlowExpired; + +#[derive(Debug)] +pub struct FlowNotAuthenticated; + +#[derive(Debug)] +pub struct FlowNotAuthorized; + +#[derive(Debug, Clone)] +pub enum AuthFlow { + Pending(FlowPending), + Authenticated(FlowAuthenticated), + Authorized(FlowAuthorized), +} + +#[derive(Debug, Clone)] +pub enum AuthFlowWithUser { + Authenticated(FlowAuthenticated), + Authorized(FlowAuthorized), +} + +impl AuthFlow { + pub fn from_request_data(data: RequestData) -> Result { if data.expires_at < chrono::Utc::now() { - return AuthFlowState::Expired; + return Err(FlowExpired); } - match (&data.did, &data.code) { - (Some(did), Some(code)) => AuthFlowState::Authorized { - did: did.clone(), - device_id: data.device_id.clone(), - code: code.clone(), - }, - (Some(did), None) => AuthFlowState::Authenticated { - did: did.clone(), - device_id: data.device_id.clone(), - }, - (None, _) => AuthFlowState::Pending, + match (data.did, data.code) { + (None, _) => Ok(AuthFlow::Pending(FlowPending { + parameters: data.parameters, + client_id: data.client_id, + client_auth: data.client_auth, + expires_at: data.expires_at, + controller_did: data.controller_did, + })), + (Some(did), None) => Ok(AuthFlow::Authenticated(FlowAuthenticated { + parameters: data.parameters, + client_id: data.client_id, + client_auth: data.client_auth, + expires_at: data.expires_at, + did, + device_id: data.device_id, + controller_did: data.controller_did, + })), + (Some(did), Some(code)) => Ok(AuthFlow::Authorized(FlowAuthorized { + parameters: data.parameters, + client_id: data.client_id, + client_auth: data.client_auth, + expires_at: data.expires_at, + did, + device_id: data.device_id, + code, + controller_did: data.controller_did, + })), } } - pub fn is_pending(&self) -> bool { - matches!(self, AuthFlowState::Pending) - } - - pub fn is_authenticated(&self) -> bool { - matches!(self, AuthFlowState::Authenticated { .. }) - } - - pub fn is_authorized(&self) -> bool { - matches!(self, AuthFlowState::Authorized { .. }) - } - - pub fn is_expired(&self) -> bool { - matches!(self, AuthFlowState::Expired) - } - - pub fn can_authenticate(&self) -> bool { - matches!(self, AuthFlowState::Pending) - } - - pub fn can_authorize(&self) -> bool { - matches!(self, AuthFlowState::Authenticated { .. }) - } - - pub fn can_exchange(&self) -> bool { - matches!(self, AuthFlowState::Authorized { .. }) - } - - pub fn did(&self) -> Option<&str> { + pub fn require_user(self) -> Result { match self { - AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => { - Some(did) - } - _ => None, + AuthFlow::Pending(_) => Err(FlowNotAuthenticated), + AuthFlow::Authenticated(a) => Ok(AuthFlowWithUser::Authenticated(a)), + AuthFlow::Authorized(a) => Ok(AuthFlowWithUser::Authorized(a)), } } - pub fn code(&self) -> Option<&str> { + pub fn require_authorized(self) -> Result { match self { - AuthFlowState::Authorized { code, .. } => Some(code), - _ => None, + AuthFlow::Authorized(a) => Ok(a), + _ => Err(FlowNotAuthorized), } } } -impl std::fmt::Display for AuthFlowState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl AuthFlowWithUser { + pub fn did(&self) -> &Did { match self { - AuthFlowState::Pending => write!(f, "pending"), - AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did), - AuthFlowState::Authorized { did, code, .. } => { - write!( - f, - "authorized ({}, code={}...)", - did, - &code[..8.min(code.len())] - ) - } - AuthFlowState::Expired => write!(f, "expired"), + AuthFlowWithUser::Authenticated(a) => &a.did, + AuthFlowWithUser::Authorized(a) => &a.did, + } + } + + pub fn device_id(&self) -> Option<&DeviceId> { + match self { + AuthFlowWithUser::Authenticated(a) => a.device_id.as_ref(), + AuthFlowWithUser::Authorized(a) => a.device_id.as_ref(), + } + } + + pub fn parameters(&self) -> &AuthorizationRequestParameters { + match self { + AuthFlowWithUser::Authenticated(a) => &a.parameters, + AuthFlowWithUser::Authorized(a) => &a.parameters, + } + } + + pub fn client_id(&self) -> &str { + match self { + AuthFlowWithUser::Authenticated(a) => &a.client_id, + AuthFlowWithUser::Authorized(a) => &a.client_id, + } + } + + pub fn controller_did(&self) -> Option<&Did> { + match self { + AuthFlowWithUser::Authenticated(a) => a.controller_did.as_ref(), + AuthFlowWithUser::Authorized(a) => a.controller_did.as_ref(), } } } @@ -406,21 +521,21 @@ mod tests { use chrono::{Duration, Utc}; fn make_request_data( - did: Option, - code: Option, + did: Option, + code: Option, expires_in: Duration, ) -> RequestData { RequestData { client_id: "test-client".into(), client_auth: None, parameters: AuthorizationRequestParameters { - response_type: "code".into(), + response_type: ResponseType::Code, client_id: "test-client".into(), redirect_uri: "https://example.com/callback".into(), scope: Some("atproto".into()), state: None, code_challenge: "test".into(), - code_challenge_method: "S256".into(), + code_challenge_method: CodeChallengeMethod::S256, response_mode: None, login_hint: None, dpop_jkt: None, @@ -435,67 +550,55 @@ mod tests { } } + fn test_did(s: &str) -> Did { + s.parse().expect("valid test DID") + } + + fn test_code(s: &str) -> Code { + Code(s.to_string()) + } + #[test] - fn test_auth_flow_state_pending() { + fn test_auth_flow_pending() { let data = make_request_data(None, None, Duration::minutes(5)); - let state = AuthFlowState::from_request_data(&data); - assert!(state.is_pending()); - assert!(!state.is_authenticated()); - assert!(!state.is_authorized()); - assert!(!state.is_expired()); - assert!(state.can_authenticate()); - assert!(!state.can_authorize()); - assert!(!state.can_exchange()); - assert!(state.did().is_none()); - assert!(state.code().is_none()); + let flow = AuthFlow::from_request_data(data).expect("should not be expired"); + assert!(matches!(flow, AuthFlow::Pending(_))); + assert!(flow.clone().require_user().is_err()); + assert!(flow.require_authorized().is_err()); } #[test] - fn test_auth_flow_state_authenticated() { - let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5)); - let state = AuthFlowState::from_request_data(&data); - assert!(!state.is_pending()); - assert!(state.is_authenticated()); - assert!(!state.is_authorized()); - assert!(!state.is_expired()); - assert!(!state.can_authenticate()); - assert!(state.can_authorize()); - assert!(!state.can_exchange()); - assert_eq!(state.did(), Some("did:plc:test")); - assert!(state.code().is_none()); + fn test_auth_flow_authenticated() { + let did = test_did("did:plc:test"); + let data = make_request_data(Some(did.clone()), None, Duration::minutes(5)); + let flow = AuthFlow::from_request_data(data).expect("should not be expired"); + assert!(matches!(flow, AuthFlow::Authenticated(_))); + let with_user = flow.clone().require_user().expect("should have user"); + assert_eq!(with_user.did(), &did); + assert!(flow.require_authorized().is_err()); } #[test] - fn test_auth_flow_state_authorized() { - let data = make_request_data( - Some("did:plc:test".into()), - Some("auth-code-123".into()), - Duration::minutes(5), - ); - let state = AuthFlowState::from_request_data(&data); - assert!(!state.is_pending()); - assert!(!state.is_authenticated()); - assert!(state.is_authorized()); - assert!(!state.is_expired()); - assert!(!state.can_authenticate()); - assert!(!state.can_authorize()); - assert!(state.can_exchange()); - assert_eq!(state.did(), Some("did:plc:test")); - assert_eq!(state.code(), Some("auth-code-123")); + fn test_auth_flow_authorized() { + let did = test_did("did:plc:test"); + let code = test_code("auth-code-123"); + let data = make_request_data(Some(did.clone()), Some(code.clone()), Duration::minutes(5)); + let flow = AuthFlow::from_request_data(data).expect("should not be expired"); + assert!(matches!(flow, AuthFlow::Authorized(_))); + let with_user = flow.clone().require_user().expect("should have user"); + assert_eq!(with_user.did(), &did); + let authorized = flow.require_authorized().expect("should be authorized"); + assert_eq!(authorized.did, did); + assert_eq!(authorized.code, code); } #[test] - fn test_auth_flow_state_expired() { - let data = make_request_data( - Some("did:plc:test".into()), - Some("code".into()), - Duration::minutes(-1), - ); - let state = AuthFlowState::from_request_data(&data); - assert!(state.is_expired()); - assert!(!state.can_authenticate()); - assert!(!state.can_authorize()); - assert!(!state.can_exchange()); + fn test_auth_flow_expired() { + let did = test_did("did:plc:test"); + let code = test_code("code"); + let data = make_request_data(Some(did), Some(code), Duration::minutes(-1)); + let result = AuthFlow::from_request_data(data); + assert!(result.is_err()); } #[test] diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index b5f3615..d07436e 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -67,6 +67,7 @@ subtle = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } tower-layer = { workspace = true } diff --git a/crates/tranquil-pds/src/api/admin/account/delete.rs b/crates/tranquil-pds/src/api/admin/account/delete.rs index 5342523..85d0ad8 100644 --- a/crates/tranquil-pds/src/api/admin/account/delete.rs +++ b/crates/tranquil-pds/src/api/admin/account/delete.rs @@ -1,5 +1,5 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::Did; @@ -9,7 +9,7 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::Deserialize; -use tracing::{error, warn}; +use tracing::warn; #[derive(Deserialize)] pub struct DeleteAccountInput { @@ -26,10 +26,7 @@ pub async fn delete_account( .user_repo .get_id_and_handle_by_did(did) .await - .map_err(|e| { - error!("DB error in delete_account: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("in delete_account")? .ok_or(ApiError::AccountNotFound) .map(|row| (row.id, row.handle))?; @@ -37,13 +34,14 @@ pub async fn delete_account( .user_repo .admin_delete_account_complete(user_id, did) .await - .map_err(|e| { - error!("Failed to delete account {}: {:?}", did, e); - ApiError::InternalError(Some("Failed to delete account".into())) - })?; + .log_db_err("deleting account")?; - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + did, + tranquil_db_traits::AccountStatus::Deleted, + ) + .await { warn!( "Failed to sequence account deletion event for {}: {}", diff --git a/crates/tranquil-pds/src/api/admin/account/email.rs b/crates/tranquil-pds/src/api/admin/account/email.rs index 0819fd9..8670499 100644 --- a/crates/tranquil-pds/src/api/admin/account/email.rs +++ b/crates/tranquil-pds/src/api/admin/account/email.rs @@ -1,7 +1,8 @@ -use crate::api::error::{ApiError, AtpJson}; +use crate::api::error::{ApiError, AtpJson, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::Did; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -9,7 +10,7 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use tracing::{error, warn}; +use tracing::warn; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -39,15 +40,12 @@ pub async fn send_email( .user_repo .get_by_did(&input.recipient_did) .await - .map_err(|e| { - error!("DB error in send_email: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("in send_email")? .ok_or(ApiError::AccountNotFound)?; let email = user.email.ok_or(ApiError::NoEmail)?; let (user_id, handle) = (user.id, user.handle); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let subject = input .subject .clone() diff --git a/crates/tranquil-pds/src/api/admin/account/info.rs b/crates/tranquil-pds/src/api/admin/account/info.rs index b1efcfc..9713618 100644 --- a/crates/tranquil-pds/src/api/admin/account/info.rs +++ b/crates/tranquil-pds/src/api/admin/account/info.rs @@ -1,4 +1,4 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::{Did, Handle}; @@ -10,7 +10,6 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tracing::error; #[derive(Deserialize)] pub struct GetAccountInfoParams { @@ -74,10 +73,7 @@ pub async fn get_account_info( .infra_repo .get_admin_account_info_by_did(¶ms.did) .await - .map_err(|e| { - error!("DB error in get_account_info: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("in get_account_info")? .ok_or(ApiError::AccountNotFound)?; let invited_by = get_invited_by(&state, account.id).await; @@ -153,7 +149,7 @@ async fn get_invites_for_user( .map(|ic| InviteCodeInfo { code: ic.code.clone(), available: ic.available_uses, - disabled: ic.disabled, + disabled: ic.state.is_disabled(), for_account: ic.for_account, created_by: ic.created_by, created_at: ic.created_at.to_rfc3339(), @@ -181,7 +177,7 @@ async fn get_invite_code_info(state: &AppState, code: &str) -> Option = accounts.iter().map(|u| u.id).collect(); @@ -272,7 +265,7 @@ pub async fn get_account_infos( let info = InviteCodeInfo { code: ic.code.clone(), available: ic.available_uses, - disabled: ic.disabled, + disabled: ic.state.is_disabled(), for_account: ic.for_account, created_by: ic.created_by, created_at: ic.created_at.to_rfc3339(), diff --git a/crates/tranquil-pds/src/api/admin/account/search.rs b/crates/tranquil-pds/src/api/admin/account/search.rs index 8f4fee1..429cfde 100644 --- a/crates/tranquil-pds/src/api/admin/account/search.rs +++ b/crates/tranquil-pds/src/api/admin/account/search.rs @@ -1,4 +1,4 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::{Did, Handle}; @@ -9,7 +9,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use tracing::error; #[derive(Deserialize)] pub struct SearchAccountsParams { @@ -66,10 +65,7 @@ pub async fn search_accounts( limit + 1, ) .await - .map_err(|e| { - error!("DB error in search_accounts: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("in search_accounts")?; let has_more = rows.len() > limit as usize; let accounts: Vec = rows diff --git a/crates/tranquil-pds/src/api/admin/account/update.rs b/crates/tranquil-pds/src/api/admin/account/update.rs index 9a2d828..98b7335 100644 --- a/crates/tranquil-pds/src/api/admin/account/update.rs +++ b/crates/tranquil-pds/src/api/admin/account/update.rs @@ -3,6 +3,7 @@ use crate::api::error::ApiError; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; +use crate::util::pds_hostname_without_port; use axum::{ Json, extract::State, @@ -69,8 +70,7 @@ pub async fn update_account_handle( { return Err(ApiError::InvalidHandle(None)); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let handle = if !input_handle.contains('.') { format!("{}.{}", input_handle, hostname_for_handles) } else { @@ -84,7 +84,7 @@ pub async fn update_account_handle( .ok() .flatten() .ok_or(ApiError::AccountNotFound)?; - let handle_for_check = Handle::new_unchecked(&handle); + let handle_for_check = unsafe { Handle::new_unchecked(&handle) }; if let Ok(true) = state .user_repo .check_handle_exists(&handle_for_check, user_id) diff --git a/crates/tranquil-pds/src/api/admin/config.rs b/crates/tranquil-pds/src/api/admin/config.rs index 6d71c73..3a72d43 100644 --- a/crates/tranquil-pds/src/api/admin/config.rs +++ b/crates/tranquil-pds/src/api/admin/config.rs @@ -1,4 +1,4 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use axum::{Json, extract::State}; @@ -56,10 +56,7 @@ pub async fn get_server_config( .infra_repo .get_server_configs(keys) .await - .map_err(|e| { - error!("DB error fetching server config: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching server config")?; let config_map: std::collections::HashMap = rows.into_iter().collect(); @@ -92,10 +89,7 @@ pub async fn update_server_config( .infra_repo .upsert_server_config("server_name", trimmed) .await - .map_err(|e| { - error!("DB error upserting server_name: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting server_name")?; } if let Some(ref color) = req.primary_color { @@ -104,19 +98,13 @@ pub async fn update_server_config( .infra_repo .delete_server_config("primary_color") .await - .map_err(|e| { - error!("DB error deleting primary_color: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting primary_color")?; } else if is_valid_hex_color(color) { state .infra_repo .upsert_server_config("primary_color", color) .await - .map_err(|e| { - error!("DB error upserting primary_color: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting primary_color")?; } else { return Err(ApiError::InvalidRequest( "Invalid primary color format (expected #RRGGBB)".into(), @@ -130,19 +118,13 @@ pub async fn update_server_config( .infra_repo .delete_server_config("primary_color_dark") .await - .map_err(|e| { - error!("DB error deleting primary_color_dark: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting primary_color_dark")?; } else if is_valid_hex_color(color) { state .infra_repo .upsert_server_config("primary_color_dark", color) .await - .map_err(|e| { - error!("DB error upserting primary_color_dark: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting primary_color_dark")?; } else { return Err(ApiError::InvalidRequest( "Invalid primary dark color format (expected #RRGGBB)".into(), @@ -156,19 +138,13 @@ pub async fn update_server_config( .infra_repo .delete_server_config("secondary_color") .await - .map_err(|e| { - error!("DB error deleting secondary_color: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting secondary_color")?; } else if is_valid_hex_color(color) { state .infra_repo .upsert_server_config("secondary_color", color) .await - .map_err(|e| { - error!("DB error upserting secondary_color: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting secondary_color")?; } else { return Err(ApiError::InvalidRequest( "Invalid secondary color format (expected #RRGGBB)".into(), @@ -182,19 +158,13 @@ pub async fn update_server_config( .infra_repo .delete_server_config("secondary_color_dark") .await - .map_err(|e| { - error!("DB error deleting secondary_color_dark: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting secondary_color_dark")?; } else if is_valid_hex_color(color) { state .infra_repo .upsert_server_config("secondary_color_dark", color) .await - .map_err(|e| { - error!("DB error upserting secondary_color_dark: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting secondary_color_dark")?; } else { return Err(ApiError::InvalidRequest( "Invalid secondary dark color format (expected #RRGGBB)".into(), @@ -217,7 +187,7 @@ pub async fn update_server_config( }; if let Some(old_cid_str) = should_delete_old { - let old_cid = CidLink::new_unchecked(old_cid_str); + let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) }; if let Ok(Some(storage_key)) = state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await { @@ -235,19 +205,13 @@ pub async fn update_server_config( .infra_repo .delete_server_config("logo_cid") .await - .map_err(|e| { - error!("DB error deleting logo_cid: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting logo_cid")?; } else { state .infra_repo .upsert_server_config("logo_cid", logo_cid) .await - .map_err(|e| { - error!("DB error upserting logo_cid: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting logo_cid")?; } } diff --git a/crates/tranquil-pds/src/api/admin/invite.rs b/crates/tranquil-pds/src/api/admin/invite.rs index d6af578..e0c91e8 100644 --- a/crates/tranquil-pds/src/api/admin/invite.rs +++ b/crates/tranquil-pds/src/api/admin/invite.rs @@ -1,5 +1,5 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use axum::{ @@ -91,10 +91,7 @@ pub async fn get_invite_codes( .infra_repo .list_invite_codes(params.cursor.as_deref(), limit, sort_order) .await - .map_err(|e| { - error!("DB error fetching invite codes: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching invite codes")?; let user_ids: Vec = codes_rows.iter().map(|r| r.created_by_user).collect(); let code_strings: Vec = codes_rows.iter().map(|r| r.code.clone()).collect(); @@ -138,7 +135,7 @@ pub async fn get_invite_codes( InviteCodeInfo { code: r.code.clone(), available: r.available_uses, - disabled: r.disabled.unwrap_or(false), + disabled: r.state().is_disabled(), for_account: creator_did.clone(), created_by: creator_did, created_at: r.created_at.to_rfc3339(), diff --git a/crates/tranquil-pds/src/api/admin/status.rs b/crates/tranquil-pds/src/api/admin/status.rs index 0ac4b44..52424c6 100644 --- a/crates/tranquil-pds/src/api/admin/status.rs +++ b/crates/tranquil-pds/src/api/admin/status.rs @@ -175,7 +175,7 @@ pub async fn update_subject_status( Some("com.atproto.admin.defs#repoRef") => { let did_str = input.subject.get("did").and_then(|d| d.as_str()); if let Some(did_str) = did_str { - let did = Did::new_unchecked(did_str); + let did = unsafe { Did::new_unchecked(did_str) }; if let Some(takedown) = &input.takedown { let takedown_ref = if takedown.applied { takedown.r#ref.as_deref() @@ -207,34 +207,24 @@ pub async fn update_subject_status( } if let Some(takedown) = &input.takedown { let status = if takedown.applied { - Some("takendown") + tranquil_db_traits::AccountStatus::Takendown } else { - None + tranquil_db_traits::AccountStatus::Active }; - if let Err(e) = crate::api::repo::record::sequence_account_event( - &state, - &did, - !takedown.applied, - status, - ) - .await + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, &did, status).await { warn!("Failed to sequence account event for takedown: {}", e); } } if let Some(deactivated) = &input.deactivated { let status = if deactivated.applied { - Some("deactivated") + tranquil_db_traits::AccountStatus::Deactivated } else { - None + tranquil_db_traits::AccountStatus::Active }; - if let Err(e) = crate::api::repo::record::sequence_account_event( - &state, - &did, - !deactivated.applied, - status, - ) - .await + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, &did, status).await { warn!("Failed to sequence account event for deactivation: {}", e); } diff --git a/crates/tranquil-pds/src/api/age_assurance.rs b/crates/tranquil-pds/src/api/age_assurance.rs index ced1298..65a19a4 100644 --- a/crates/tranquil-pds/src/api/age_assurance.rs +++ b/crates/tranquil-pds/src/api/age_assurance.rs @@ -33,13 +33,13 @@ pub async fn get_age_assurance_state() -> Response { } async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option { - let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); + let auth_header = crate::util::get_header_str(headers, "Authorization"); tracing::debug!(?auth_header, "age assurance: extracting token"); let extracted = extract_auth_token_from_header(auth_header)?; tracing::debug!("age assurance: got token, validating"); - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let dpop_proof = crate::util::get_header_str(headers, "DPoP"); let http_uri = "/"; let auth_user = match validate_token_with_dpop( diff --git a/crates/tranquil-pds/src/api/delegation.rs b/crates/tranquil-pds/src/api/delegation.rs index 7d48aa4..b4487aa 100644 --- a/crates/tranquil-pds/src/api/delegation.rs +++ b/crates/tranquil-pds/src/api/delegation.rs @@ -1,14 +1,18 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{Active, Auth}; -use crate::delegation::{DelegationActionType, SCOPE_PRESETS, scopes}; -use crate::state::{AppState, RateLimitKind}; +use crate::delegation::{ + DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, + verify_can_be_controller, verify_can_control_accounts, +}; +use crate::rate_limit::{AccountCreationLimit, RateLimited}; +use crate::state::AppState; use crate::types::{Did, Handle, Nsid, Rkey}; -use crate::util::extract_client_ip; +use crate::util::{pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{IntoResponse, Response}, }; use jacquard_common::types::{integer::LimitedU32, string::Tid}; @@ -57,7 +61,7 @@ pub async fn list_controllers( .map(|c| ControllerInfo { did: c.did, handle: c.handle, - granted_scopes: c.granted_scopes, + granted_scopes: c.granted_scopes.into_string(), granted_at: c.granted_at, is_active: c.is_active, }) @@ -69,7 +73,7 @@ pub async fn list_controllers( #[derive(Debug, Deserialize)] pub struct AddControllerInput { pub controller_did: Did, - pub granted_scopes: String, + pub granted_scopes: ValidatedDelegationScope, } pub async fn add_controller( @@ -77,10 +81,6 @@ pub async fn add_controller( auth: Auth, Json(input): Json, ) -> Result { - if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) { - return Ok(ApiError::InvalidScopes(e).into_response()); - } - let controller_exists = state .user_repo .get_by_did(&input.controller_did) @@ -93,51 +93,23 @@ pub async fn add_controller( return Ok(ApiError::ControllerNotFound.into_response()); } - match state.delegation_repo.controls_any_accounts(&auth.did).await { - Ok(true) => { - return Ok(ApiError::InvalidDelegation( - "Cannot add controllers to an account that controls other accounts".into(), - ) - .into_response()); - } - Err(e) => { - tracing::error!("Failed to check delegation status: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to verify delegation status".into())) - .into_response(), - ); - } - Ok(false) => {} - } + let can_add = match verify_can_add_controllers(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; - match state - .delegation_repo - .has_any_controllers(&input.controller_did) - .await - { - Ok(true) => { - return Ok(ApiError::InvalidDelegation( - "Cannot add a controlled account as a controller".into(), - ) - .into_response()); - } - Err(e) => { - tracing::error!("Failed to check controller status: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to verify controller status".into())) - .into_response(), - ); - } - Ok(false) => {} - } + let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; match state .delegation_repo .create_delegation( - &auth.did, - &input.controller_did, + can_add.did(), + can_be_controller.did(), &input.granted_scopes, - &auth.did, + can_add.did(), ) .await { @@ -145,12 +117,12 @@ pub async fn add_controller( let _ = state .delegation_repo .log_delegation_action( - &auth.did, - &auth.did, - Some(&input.controller_did), + can_add.did(), + can_add.did(), + Some(can_be_controller.did()), DelegationActionType::GrantCreated, Some(serde_json::json!({ - "granted_scopes": input.granted_scopes + "granted_scopes": input.granted_scopes.as_str() })), None, None, @@ -235,7 +207,7 @@ pub async fn remove_controller( #[derive(Debug, Deserialize)] pub struct UpdateControllerScopesInput { pub controller_did: Did, - pub granted_scopes: String, + pub granted_scopes: ValidatedDelegationScope, } pub async fn update_controller_scopes( @@ -243,10 +215,6 @@ pub async fn update_controller_scopes( auth: Auth, Json(input): Json, ) -> Result { - if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) { - return Ok(ApiError::InvalidScopes(e).into_response()); - } - match state .delegation_repo .update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes) @@ -261,7 +229,7 @@ pub async fn update_controller_scopes( Some(&input.controller_did), DelegationActionType::ScopesModified, Some(serde_json::json!({ - "new_scopes": input.granted_scopes + "new_scopes": input.granted_scopes.as_str() })), None, None, @@ -326,7 +294,7 @@ pub async fn list_controlled_accounts( .map(|a| DelegatedAccountInfo { did: a.did, handle: a.handle, - granted_scopes: a.granted_scopes, + granted_scopes: a.granted_scopes.into_string(), granted_at: a.granted_at, }) .collect(), @@ -443,7 +411,7 @@ pub async fn get_scope_presets() -> Response { pub struct CreateDelegatedAccountInput { pub handle: String, pub email: Option, - pub controller_scopes: String, + pub controller_scopes: ValidatedDelegationScope, pub invite_code: Option, } @@ -456,45 +424,17 @@ pub struct CreateDelegatedAccountResponse { pub async fn create_delegated_account( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, auth: Auth, Json(input): Json, ) -> Result { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) - .await - { - warn!(ip = %client_ip, "Delegated account creation rate limit exceeded"); - return Ok(ApiError::RateLimitExceeded(Some( - "Too many account creation attempts. Please try again later.".into(), - )) - .into_response()); - } + let can_control = match verify_can_control_accounts(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; - if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) { - return Ok(ApiError::InvalidScopes(e).into_response()); - } - - match state.delegation_repo.has_any_controllers(&auth.did).await { - Ok(true) => { - return Ok(ApiError::InvalidDelegation( - "Cannot create delegated accounts from a controlled account".into(), - ) - .into_response()); - } - Err(e) => { - tracing::error!("Failed to check controller status: {:?}", e); - return Ok( - ApiError::InternalError(Some("Failed to verify controller status".into())) - .into_response(), - ); - } - Ok(false) => {} - } - - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_handles); let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { @@ -527,15 +467,10 @@ pub async fn create_delegated_account( return Ok(ApiError::InvalidEmail.into_response()); } - if let Some(ref code) = input.invite_code { - let valid = state - .infra_repo - .is_invite_code_valid(code) - .await - .unwrap_or(false); - - if !valid { - return Ok(ApiError::InvalidInviteCode.into_response()); + let validated_invite_code = if let Some(ref code) = input.invite_code { + match state.infra_repo.validate_invite_code(code).await { + Ok(validated) => Some(validated), + Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), } } else { let invite_required = std::env::var("INVITE_CODE_REQUIRED") @@ -544,7 +479,8 @@ pub async fn create_delegated_account( if invite_required { return Ok(ApiError::InviteCodeRequired.into_response()); } - } + None + }; use k256::ecdsa::SigningKey; use rand::rngs::OsRng; @@ -593,9 +529,9 @@ pub async fn create_delegated_account( .into_response()); } - let did = Did::new_unchecked(&genesis_result.did); - let handle = Handle::new_unchecked(&handle); - info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account"); + let did = unsafe { Did::new_unchecked(&genesis_result.did) }; + let handle = unsafe { Handle::new_unchecked(&handle) }; + info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { Ok(bytes) => bytes, @@ -635,8 +571,8 @@ pub async fn create_delegated_account( handle: handle.clone(), email: email.clone(), did: did.clone(), - controller_did: auth.did.clone(), - controller_scopes: input.controller_scopes.clone(), + controller_did: can_control.did().clone(), + controller_scopes: input.controller_scopes.as_str().to_string(), encrypted_key_bytes, encryption_version: crate::config::ENCRYPTION_VERSION, commit_cid: commit_cid.to_string(), @@ -645,7 +581,7 @@ pub async fn create_delegated_account( invite_code: input.invite_code.clone(), }; - let _user_id = match state + let user_id = match state .user_repo .create_delegated_account(&create_input) .await @@ -663,12 +599,26 @@ pub async fn create_delegated_account( } }; + if let Some(validated) = validated_invite_code + && let Err(e) = state + .infra_repo + .record_invite_code_use(&validated, user_id) + .await + { + warn!("Failed to record invite code use for {}: {:?}", did, e); + } + if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await { warn!("Failed to sequence identity event for {}: {}", did, e); } - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did, + tranquil_db_traits::AccountStatus::Active, + ) + .await { warn!("Failed to sequence account event for {}: {}", did, e); } @@ -677,8 +627,8 @@ pub async fn create_delegated_account( "$type": "app.bsky.actor.profile", "displayName": handle }); - let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile"); - let profile_rkey = Rkey::new_unchecked("self"); + let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; + let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did, @@ -700,7 +650,7 @@ pub async fn create_delegated_account( DelegationActionType::GrantCreated, Some(json!({ "account_created": true, - "granted_scopes": input.controller_scopes + "granted_scopes": input.controller_scopes.as_str() })), None, None, diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 09dda55..b5a8278 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -694,6 +694,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(e: crate::rate_limit::UserRateLimitError) -> Self { + Self::RateLimitExceeded(e.message) + } +} + #[allow(clippy::result_large_err)] pub fn parse_did(s: &str) -> Result { s.parse() @@ -756,3 +762,16 @@ fn extract_json_error_message(rejection: &JsonRejection) -> String { _ => "Invalid request body".to_string(), } } + +pub trait DbResultExt { + fn log_db_err(self, ctx: &str) -> Result; +} + +impl DbResultExt for Result { + fn log_db_err(self, ctx: &str) -> Result { + self.map_err(|e| { + tracing::error!("DB error {}: {:?}", ctx, e); + ApiError::DatabaseError + }) + } +} diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index e7ce67e..4ff1a0a 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -3,8 +3,10 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token}; use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{AccountCreationLimit, RateLimited}; +use crate::state::AppState; use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; +use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; use axum::{ Json, @@ -22,21 +24,6 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "unknown".to_string() -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateAccountInput { @@ -68,6 +55,7 @@ pub struct CreateAccountOutput { pub async fn create_account( State(state): State, + _rate_limit: RateLimited, headers: HeaderMap, Json(input): Json, ) -> Response { @@ -84,20 +72,9 @@ pub async fn create_account( } else { info!("create_account called"); } - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) - .await - { - warn!(ip = %client_ip, "Account creation rate limit exceeded"); - return ApiError::RateLimitExceeded(Some( - "Too many account creation attempts. Please try again later.".into(), - )) - .into_response(); - } let migration_auth = if let Some(extracted) = - extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) + extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization")) { let token = extracted.token; if is_service_token(&token) { @@ -143,7 +120,7 @@ pub async fn create_account( if (is_migration || is_did_web_byod) && let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) { - if provided_did != auth_did { + if provided_did != auth_did.as_str() { info!( "[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}", auth_did, provided_did @@ -164,8 +141,7 @@ pub async fn create_account( } } - let hostname_for_validation = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname_for_validation = pds_hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_validation); let validated_short_handle = if !input.handle.contains('.') @@ -242,8 +218,8 @@ pub async fn create_account( _ => return ApiError::InvalidVerificationChannel.into_response(), }) }; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); let pds_endpoint = format!("https://{}", hostname); let suffix = format!(".{}", hostname_for_handles); let handle = if input.handle.ends_with(&suffix) { @@ -308,7 +284,7 @@ pub async fn create_account( } if !is_did_web_byod && let Err(e) = - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await + verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await { return ApiError::InvalidDid(e).into_response(); } @@ -322,13 +298,9 @@ pub async fn create_account( d.clone() } else if d.starts_with("did:web:") { if !is_did_web_byod - && let Err(e) = verify_did_web( - d, - &hostname, - &input.handle, - input.signing_key.as_deref(), - ) - .await + && let Err(e) = + verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()) + .await { return ApiError::InvalidDid(e).into_response(); } @@ -408,8 +380,8 @@ pub async fn create_account( }; if is_migration { let reactivate_input = tranquil_db_traits::MigrationReactivationInput { - did: Did::new_unchecked(&did), - new_handle: Handle::new_unchecked(&handle), + did: unsafe { Did::new_unchecked(&did) }, + new_handle: unsafe { Handle::new_unchecked(&handle) }, new_email: email.clone(), }; match state @@ -463,12 +435,12 @@ pub async fn create_account( } }; let session_data = tranquil_db_traits::SessionTokenCreate { - did: Did::new_unchecked(&did), + did: unsafe { Did::new_unchecked(&did) }, access_jti: access_meta.jti.clone(), refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - legacy_login: false, + login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, scope: None, controller_did: None, @@ -478,8 +450,7 @@ pub async fn create_account( error!("Error creating session: {:?}", e); return ApiError::InternalError(None).into_response(); } - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let verification_required = if let Some(ref user_email) = email { let token = crate::auth::verification_token::generate_migration_token(&did, user_email); @@ -491,7 +462,7 @@ pub async fn create_account( reactivated.user_id, user_email, &formatted_token, - &hostname, + hostname, ) .await { @@ -505,7 +476,7 @@ pub async fn create_account( axum::http::StatusCode::OK, Json(CreateAccountOutput { handle: handle.clone().into(), - did: Did::new_unchecked(&did), + did: unsafe { Did::new_unchecked(&did) }, did_doc: state.did_resolver.resolve_did_document(&did).await, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, @@ -529,7 +500,7 @@ pub async fn create_account( } } - let handle_typed = Handle::new_unchecked(&handle); + let handle_typed = unsafe { Handle::new_unchecked(&handle) }; let handle_available = match state .user_repo .check_handle_available_for_new_account(&handle_typed) @@ -613,7 +584,7 @@ pub async fn create_account( } }; let rev = Tid::now(LimitedU32::MIN); - let did_for_commit = Did::new_unchecked(&did); + let did_for_commit = unsafe { Did::new_unchecked(&did) }; let (commit_bytes, _sig) = match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) { Ok(result) => result, @@ -649,9 +620,9 @@ pub async fn create_account( }; let create_input = tranquil_db_traits::CreatePasswordAccountInput { - handle: Handle::new_unchecked(&handle), + handle: unsafe { Handle::new_unchecked(&handle) }, email: email.clone(), - did: Did::new_unchecked(&did), + did: unsafe { Did::new_unchecked(&did) }, password_hash, preferred_comms_channel, discord_id: input @@ -701,8 +672,8 @@ pub async fn create_account( }; let user_id = create_result.user_id; if !is_migration && !is_did_web_byod { - let did_typed = Did::new_unchecked(&did); - let handle_typed = Handle::new_unchecked(&handle); + let did_typed = unsafe { Did::new_unchecked(&did) }; + let handle_typed = unsafe { Handle::new_unchecked(&handle) }; if let Err(e) = crate::api::repo::record::sequence_identity_event( &state, &did_typed, @@ -712,8 +683,12 @@ pub async fn create_account( { warn!("Failed to sequence identity event for {}: {}", did, e); } - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did_typed, + tranquil_db_traits::AccountStatus::Active, + ) + .await { warn!("Failed to sequence account event for {}: {}", did, e); } @@ -742,8 +717,8 @@ pub async fn create_account( "$type": "app.bsky.actor.profile", "displayName": input.handle }); - let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile"); - let profile_rkey = Rkey::new_unchecked("self"); + let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; + let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did_typed, @@ -756,7 +731,7 @@ pub async fn create_account( warn!("Failed to create default profile for {}: {}", did, e); } } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if !is_migration { if let Some(ref recipient) = verification_recipient { let verification_token = crate::auth::verification_token::generate_signup_token( @@ -772,7 +747,7 @@ pub async fn create_account( verification_channel, recipient, &formatted_token, - &hostname, + hostname, ) .await { @@ -791,7 +766,7 @@ pub async fn create_account( user_id, user_email, &formatted_token, - &hostname, + hostname, ) .await { @@ -816,12 +791,12 @@ pub async fn create_account( } }; let session_data = tranquil_db_traits::SessionTokenCreate { - did: Did::new_unchecked(&did), + did: unsafe { Did::new_unchecked(&did) }, access_jti: access_meta.jti.clone(), refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - legacy_login: false, + login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, scope: None, controller_did: None, @@ -845,7 +820,7 @@ pub async fn create_account( StatusCode::OK, Json(CreateAccountOutput { handle: handle.clone().into(), - did: Did::new_unchecked(&did), + did: unsafe { Did::new_unchecked(&did) }, did_doc, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index 3919fd7..cc059b6 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -1,8 +1,12 @@ use crate::api::{ApiError, DidResponse, EmptyResponse}; use crate::auth::{Auth, NotTakendown}; use crate::plc::signing_key_to_did_key; +use crate::rate_limit::{ + HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message, +}; use crate::state::AppState; use crate::types::Handle; +use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::{Path, Query, State}, @@ -101,20 +105,17 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result, headers: HeaderMap) -> Response { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let host_header = headers - .get("host") - .and_then(|h| h.to_str().ok()) - .unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_without_port = pds_hostname_without_port(); + let host_header = get_header_str(&headers, "host").unwrap_or(hostname); let host_without_port = host_header.split(':').next().unwrap_or(host_header); - let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname); if host_without_port != hostname_without_port && host_without_port.ends_with(&format!(".{}", hostname_without_port)) { let handle = host_without_port .strip_suffix(&format!(".{}", hostname_without_port)) .unwrap_or(host_without_port); - return serve_subdomain_did_doc(&state, handle, &hostname).await; + return serve_subdomain_did_doc(&state, handle, hostname).await; } let did = if hostname.contains(':') { format!("did:web:{}", hostname.replace(':', "%3A")) @@ -257,8 +258,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s } pub async fn user_did_doc(State(state): State, Path(handle): Path) -> Response { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); let current_handle = format!("{}.{}", handle, hostname_for_handles); let current_handle_typed: Handle = match current_handle.parse() { Ok(h) => h, @@ -531,7 +532,7 @@ pub async fn get_recommended_did_credentials( ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into())) })?; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let pds_endpoint = format!("https://{}", hostname); let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes) .map_err(|_| ApiError::InternalError(None))?; @@ -585,22 +586,18 @@ pub async fn update_handle( return Ok(e); } let did = auth.did.clone(); - if !state - .check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did) - .await - { - return Err(ApiError::RateLimitExceeded(Some( - "Too many handle updates. Try again later.".into(), - ))); - } - if !state - .check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did) - .await - { - return Err(ApiError::RateLimitExceeded(Some( - "Daily handle update limit exceeded.".into(), - ))); - } + let _rate_limit = check_user_rate_limit_with_message::( + &state, + &did, + "Too many handle updates. Try again later.", + ) + .await?; + let _daily_rate_limit = check_user_rate_limit_with_message::( + &state, + &did, + "Daily handle update limit exceeded.", + ) + .await?; let user_row = state .user_repo .get_id_and_handle_by_did(&did) @@ -639,8 +636,7 @@ pub async fn update_handle( "Inappropriate language in handle".into(), ))); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let suffix = format!(".{}", hostname_for_handles); let is_service_domain = crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles); @@ -656,7 +652,7 @@ pub async fn update_handle( format!("{}.{}", new_handle, hostname_for_handles) }; if full_handle == current_handle { - let handle_typed = Handle::new_unchecked(&full_handle); + let handle_typed = unsafe { Handle::new_unchecked(&full_handle) }; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)) .await @@ -679,7 +675,7 @@ pub async fn update_handle( full_handle } else { if new_handle == current_handle { - let handle_typed = Handle::new_unchecked(&new_handle); + let handle_typed = unsafe { Handle::new_unchecked(&new_handle) }; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)) .await @@ -772,7 +768,7 @@ pub async fn update_plc_handle( } pub async fn well_known_atproto_did(State(state): State, headers: HeaderMap) -> Response { - let host = match headers.get("host").and_then(|h| h.to_str().ok()) { + let host = match crate::util::get_header_str(&headers, "host") { Some(h) => h, None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), }; diff --git a/crates/tranquil-pds/src/api/identity/plc/request.rs b/crates/tranquil-pds/src/api/identity/plc/request.rs index de5f71e..45a1ef7 100644 --- a/crates/tranquil-pds/src/api/identity/plc/request.rs +++ b/crates/tranquil-pds/src/api/identity/plc/request.rs @@ -1,13 +1,14 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Auth, Permissive}; use crate::state::AppState; +use crate::util::pds_hostname; use axum::{ extract::State, response::{IntoResponse, Response}, }; use chrono::{Duration, Utc}; -use tracing::{error, info, warn}; +use tracing::{info, warn}; fn generate_plc_token() -> String { crate::util::generate_token_code() @@ -28,10 +29,7 @@ pub async fn request_plc_operation_signature( .user_repo .get_id_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user id")? .ok_or(ApiError::AccountNotFound)?; let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await; @@ -41,18 +39,15 @@ pub async fn request_plc_operation_signature( .infra_repo .insert_plc_token(user_id, &plc_token, expires_at) .await - .map_err(|e| { - error!("Failed to create PLC token: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("creating PLC token")?; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, &plc_token, - &hostname, + hostname, ) .await { diff --git a/crates/tranquil-pds/src/api/identity/plc/sign.rs b/crates/tranquil-pds/src/api/identity/plc/sign.rs index 3b97e79..040dbc1 100644 --- a/crates/tranquil-pds/src/api/identity/plc/sign.rs +++ b/crates/tranquil-pds/src/api/identity/plc/sign.rs @@ -1,4 +1,5 @@ use crate::api::ApiError; +use crate::api::error::DbResultExt; use crate::auth::{Auth, Permissive}; use crate::circuit_breaker::with_circuit_breaker; use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation}; @@ -64,20 +65,14 @@ pub async fn sign_plc_operation( .user_repo .get_id_by_did(did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user id")? .ok_or(ApiError::AccountNotFound)?; let token_expiry = state .infra_repo .get_plc_token_expiry(user_id, token) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching PLC token expiry")? .ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?; if Utc::now() > token_expiry { @@ -88,10 +83,7 @@ pub async fn sign_plc_operation( .user_repo .get_user_key_by_id(user_id) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user key")? .ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?; let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) diff --git a/crates/tranquil-pds/src/api/identity/plc/submit.rs b/crates/tranquil-pds/src/api/identity/plc/submit.rs index adf6ee8..5c11db1 100644 --- a/crates/tranquil-pds/src/api/identity/plc/submit.rs +++ b/crates/tranquil-pds/src/api/identity/plc/submit.rs @@ -1,8 +1,10 @@ +use crate::api::error::DbResultExt; use crate::api::{ApiError, EmptyResponse}; use crate::auth::{Auth, Permissive}; use crate::circuit_breaker::with_circuit_breaker; use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; use crate::state::AppState; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -40,26 +42,20 @@ pub async fn submit_plc_operation( .map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?; let op = &input.operation; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let public_url = format!("https://{}", hostname); let user = state .user_repo .get_id_and_handle_by_did(did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user")? .ok_or(ApiError::AccountNotFound)?; let key_row = state .user_repo .get_user_key_by_id(user.id) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user key")? .ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?; let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) diff --git a/crates/tranquil-pds/src/api/notification_prefs.rs b/crates/tranquil-pds/src/api/notification_prefs.rs index a82f3ce..e8dda02 100644 --- a/crates/tranquil-pds/src/api/notification_prefs.rs +++ b/crates/tranquil-pds/src/api/notification_prefs.rs @@ -1,6 +1,7 @@ use crate::api::error::ApiError; use crate::auth::{Active, Auth}; use crate::state::AppState; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -9,11 +10,12 @@ use axum::{ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::info; +use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NotificationPrefsResponse { - pub preferred_channel: String, + pub preferred_channel: CommsChannel, pub email: String, pub discord_id: Option, pub discord_verified: bool, @@ -50,9 +52,9 @@ pub async fn get_notification_prefs( #[serde(rename_all = "camelCase")] pub struct NotificationHistoryEntry { pub created_at: String, - pub channel: String, - pub comms_type: String, - pub status: String, + pub channel: CommsChannel, + pub comms_type: CommsType, + pub status: CommsStatus, pub subject: Option, pub body: String, } @@ -81,30 +83,29 @@ pub async fn get_notification_history( .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; let sensitive_types = [ - "email_verification", - "password_reset", - "email_update", - "two_factor_code", - "passkey_recovery", - "migration_verification", - "plc_operation", - "channel_verification", - "signup_verification", + CommsType::EmailVerification, + CommsType::PasswordReset, + CommsType::EmailUpdate, + CommsType::TwoFactorCode, + CommsType::PasskeyRecovery, + CommsType::MigrationVerification, + CommsType::PlcOperation, + CommsType::ChannelVerification, ]; let notifications = rows .iter() .map(|row| { - let body = if sensitive_types.contains(&row.comms_type.as_str()) { + let body = if sensitive_types.contains(&row.comms_type) { "[Code redacted for security]".to_string() } else { row.body.clone() }; NotificationHistoryEntry { created_at: row.created_at.to_rfc3339(), - channel: row.channel.clone(), - comms_type: row.comms_type.clone(), - status: row.status.clone(), + channel: row.channel, + comms_type: row.comms_type, + status: row.status, subject: row.subject.clone(), body, } @@ -145,7 +146,7 @@ pub async fn request_channel_verification( let formatted_token = crate::auth::verification_token::format_token_for_display(&token); if channel == "email" { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let handle_str = handle.unwrap_or("user"); crate::comms::comms_repo::enqueue_email_update( state.infra_repo.as_ref(), @@ -153,7 +154,7 @@ pub async fn request_channel_verification( identifier, handle_str, &formatted_token, - &hostname, + hostname, ) .await .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; @@ -200,19 +201,24 @@ pub async fn update_notification_prefs( let mut verification_required: Vec = Vec::new(); - if let Some(ref channel) = input.preferred_channel { - let valid_channels = ["email", "discord", "telegram", "signal"]; - if !valid_channels.contains(&channel.as_str()) { - return Err(ApiError::InvalidRequest( - "Invalid channel. Must be one of: email, discord, telegram, signal".into(), - )); - } + if let Some(ref channel_str) = input.preferred_channel { + let channel = match channel_str.as_str() { + "email" => CommsChannel::Email, + "discord" => CommsChannel::Discord, + "telegram" => CommsChannel::Telegram, + "signal" => CommsChannel::Signal, + _ => { + return Err(ApiError::InvalidRequest( + "Invalid channel. Must be one of: email, discord, telegram, signal".into(), + )); + } + }; state .user_repo .update_preferred_comms_channel(&auth.did, channel) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, channel = %channel, "Updated preferred notification channel"); + info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel"); } if let Some(ref new_email) = input.email { diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index 5e4f229..fce2100 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -3,6 +3,7 @@ use std::convert::Infallible; use crate::api::error::ApiError; use crate::api::proxy_client::proxy_client; use crate::state::AppState; +use crate::util::get_header_str; use axum::{ body::Bytes, extract::{RawQuery, Request, State}, @@ -191,11 +192,7 @@ async fn proxy_handler( .into_response(); } - let Some(proxy_header) = headers - .get("atproto-proxy") - .and_then(|h| h.to_str().ok()) - .map(String::from) - else { + let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else { return ApiError::InvalidRequest("Missing required atproto-proxy header".into()) .into_response(); }; @@ -217,10 +214,10 @@ async fn proxy_handler( let mut auth_header_val = headers.get("Authorization").cloned(); if let Some(extracted) = crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), + crate::util::get_header_str(&headers, "Authorization"), ) { let token = extracted.token; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let dpop_proof = crate::util::get_header_str(&headers, "DPoP"); let http_uri = crate::util::build_full_url(&uri.to_string()); match crate::auth::validate_token_with_dpop( diff --git a/crates/tranquil-pds/src/api/repo/blob.rs b/crates/tranquil-pds/src/api/repo/blob.rs index 7b6e05d..8d5dbb8 100644 --- a/crates/tranquil-pds/src/api/repo/blob.rs +++ b/crates/tranquil-pds/src/api/repo/blob.rs @@ -1,9 +1,9 @@ -use crate::api::error::ApiError; -use crate::auth::{Auth, AuthAny, NotTakendown, Permissive}; +use crate::api::error::{ApiError, DbResultExt}; +use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope}; use crate::delegation::DelegationActionType; use crate::state::AppState; use crate::types::{CidLink, Did}; -use crate::util::get_max_blob_size; +use crate::util::{get_header_str, get_max_blob_size}; use axum::body::Body; use axum::{ Json, @@ -56,18 +56,16 @@ pub async fn upload_blob( if user.status.is_takendown() { return Err(ApiError::AccountTakedown); } - let mime_type_for_check = headers - .get("content-type") - .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream"); - if let Err(e) = crate::auth::scope_check::check_blob_scope( - user.is_oauth(), - user.scope.as_deref(), - mime_type_for_check, - ) { - return Ok(e); - } - (user.did.clone(), user.controller_did.clone()) + let mime_type_for_check = + get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); + let scope_proof = match user.verify_blob_upload(mime_type_for_check) { + Ok(proof) => proof, + Err(e) => return Ok(e.into_response()), + }; + ( + scope_proof.principal_did().into_did(), + scope_proof.controller_did().map(|c| c.into_did()), + ) } }; @@ -80,10 +78,8 @@ pub async fn upload_blob( return Err(ApiError::Forbidden); } - let client_mime_hint = headers - .get("content-type") - .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream"); + let client_mime_hint = + get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); let user_id = state .user_repo @@ -140,7 +136,7 @@ pub async fn upload_blob( }; let cid = Cid::new_v1(0x55, multihash); let cid_str = cid.to_string(); - let cid_link: CidLink = CidLink::new_unchecked(&cid_str); + let cid_link: CidLink = unsafe { CidLink::new_unchecked(&cid_str) }; let storage_key = cid_str.clone(); info!( @@ -232,10 +228,7 @@ pub async fn list_missing_blobs( .user_repo .get_by_did(did) .await - .map_err(|e| { - error!("DB error fetching user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user")? .ok_or(ApiError::InternalError(None))?; let limit = params.limit.unwrap_or(500).clamp(1, 1000); @@ -244,10 +237,7 @@ pub async fn list_missing_blobs( .blob_repo .list_missing_blobs(user.id, cursor, limit + 1) .await - .map_err(|e| { - error!("DB error fetching missing blobs: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching missing blobs")?; let has_more = missing.len() > limit as usize; let blobs: Vec = missing diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index 32fa141..6881360 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -1,5 +1,5 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::api::repo::record::create_signed_commit; use crate::auth::{Auth, NotTakendown}; use crate::state::AppState; @@ -49,10 +49,7 @@ pub async fn import_repo( .user_repo .get_by_did(did) .await - .map_err(|e| { - error!("DB error fetching user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user")? .ok_or(ApiError::AccountNotFound)?; if user.takedown_ref.is_some() { return Err(ApiError::AccountTakedown); @@ -207,10 +204,9 @@ pub async fn import_repo( let record_uri = AtUri::from_parts(did.as_str(), &record.collection, &record.rkey); record.blob_refs.iter().map(move |blob_ref| { - ( - record_uri.clone(), - CidLink::new_unchecked(blob_ref.cid.clone()), - ) + (record_uri.clone(), unsafe { + CidLink::new_unchecked(blob_ref.cid.clone()) + }) }) }) .collect(); @@ -275,7 +271,7 @@ pub async fn import_repo( error!("Failed to store new commit block: {:?}", e); ApiError::InternalError(None) })?; - let new_root_cid_link = CidLink::new_unchecked(new_root_cid.to_string()); + let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) }; state .repo_repo .update_repo_root(user_id, &new_root_cid_link, &new_rev_str) @@ -368,8 +364,8 @@ async fn sequence_import_event( ) -> Result<(), tranquil_db::DbError> { let data = tranquil_db::CommitEventData { did: did.clone(), - event_type: "commit".to_string(), - commit_cid: Some(CidLink::new_unchecked(commit_cid)), + event_type: tranquil_db::RepoEventType::Commit, + commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }), prev_cid: None, ops: Some(serde_json::json!([])), blobs: Some(vec![]), diff --git a/crates/tranquil-pds/src/api/repo/meta.rs b/crates/tranquil-pds/src/api/repo/meta.rs index b2d8bb8..d299afe 100644 --- a/crates/tranquil-pds/src/api/repo/meta.rs +++ b/crates/tranquil-pds/src/api/repo/meta.rs @@ -1,6 +1,7 @@ use crate::api::error::ApiError; use crate::state::AppState; use crate::types::AtIdentifier; +use crate::util::pds_hostname_without_port; use axum::{ Json, extract::{Query, State}, @@ -18,8 +19,7 @@ pub async fn describe_repo( State(state): State, Query(input): Query, ) -> Response { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let user_row = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, diff --git a/crates/tranquil-pds/src/api/repo/record/batch.rs b/crates/tranquil-pds/src/api/repo/record/batch.rs index 2f8b7b7..da71208 100644 --- a/crates/tranquil-pds/src/api/repo/record/batch.rs +++ b/crates/tranquil-pds/src/api/repo/record/batch.rs @@ -1,7 +1,12 @@ use super::validation::validate_record_with_status; +use super::validation_mode::{ValidationMode, deserialize_validation_mode}; use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; -use crate::auth::{Active, Auth}; +use crate::auth::{ + Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated, + verify_batch_write_scopes, +}; +use crate::cid_types::CommitCid; use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -34,7 +39,7 @@ async fn process_single_write( write: &WriteOp, acc: WriteAccumulator, did: &Did, - validate: Option, + validate: ValidationMode, tracking_store: &TrackingBlockStore, ) -> Result { let WriteAccumulator { @@ -51,19 +56,17 @@ async fn process_single_write( rkey, value, } => { - let validation_status = match validate { - Some(false) => None, - _ => { - let require_lexicon = validate == Some(true); - match validate_record_with_status( - value, - collection, - rkey.as_ref(), - require_lexicon, - ) { - Ok(status) => Some(status), - Err(err_response) => return Err(*err_response), - } + let validation_status = if validate.should_skip() { + None + } else { + match validate_record_with_status( + value, + collection, + rkey.as_ref(), + validate.requires_lexicon(), + ) { + Ok(status) => Some(status), + Err(err_response) => return Err(*err_response), } }; all_blob_cids.extend(extract_blob_cids(value)); @@ -104,19 +107,17 @@ async fn process_single_write( rkey, value, } => { - let validation_status = match validate { - Some(false) => None, - _ => { - let require_lexicon = validate == Some(true); - match validate_record_with_status( - value, - collection, - Some(rkey), - require_lexicon, - ) { - Ok(status) => Some(status), - Err(err_response) => return Err(*err_response), - } + let validation_status = if validate.should_skip() { + None + } else { + match validate_record_with_status( + value, + collection, + Some(rkey), + validate.requires_lexicon(), + ) { + Ok(status) => Some(status), + Err(err_response) => return Err(*err_response), } }; all_blob_cids.extend(extract_blob_cids(value)); @@ -181,7 +182,7 @@ async fn process_writes( writes: &[WriteOp], initial_mst: Mst, did: &Did, - validate: Option, + validate: ValidationMode, tracking_store: &TrackingBlockStore, ) -> Result { use futures::stream::{self, TryStreamExt}; @@ -222,7 +223,8 @@ pub enum WriteOp { #[serde(rename_all = "camelCase")] pub struct ApplyWritesInput { pub repo: AtIdentifier, - pub validate: Option, + #[serde(default, deserialize_with = "deserialize_validation_mode")] + pub validate: ValidationMode, pub writes: Vec, pub swap_commit: Option, } @@ -270,36 +272,7 @@ pub async fn apply_writes( input.repo, input.writes.len() ); - let did = auth.did.clone(); - let is_oauth = auth.is_oauth(); - let scope = auth.scope.clone(); - let controller_did = auth.controller_did.clone(); - if input.repo.as_str() != did { - return Err(ApiError::InvalidRepo( - "Repo does not match authenticated user".into(), - )); - } - if state - .user_repo - .is_account_migrated(&did) - .await - .unwrap_or(false) - { - return Err(ApiError::AccountMigrated); - } - let is_verified = state - .user_repo - .has_verified_comms_channel(&did) - .await - .unwrap_or(false); - let is_delegated = state - .delegation_repo - .is_delegated_account(&did) - .await - .unwrap_or(false); - if !is_verified && !is_delegated { - return Err(ApiError::AccountNotVerified); - } + if input.writes.is_empty() { return Err(ApiError::InvalidRequest("writes array is empty".into())); } @@ -310,74 +283,40 @@ pub async fn apply_writes( ))); } - let has_custom_scope = scope - .as_ref() - .map(|s| s != "com.atproto.access") - .unwrap_or(false); - if is_oauth || has_custom_scope { - use std::collections::HashSet; - let create_collections: HashSet<&Nsid> = input - .writes - .iter() - .filter_map(|w| { - if let WriteOp::Create { collection, .. } = w { - Some(collection) - } else { - None - } - }) - .collect(); - let update_collections: HashSet<&Nsid> = input - .writes - .iter() - .filter_map(|w| { - if let WriteOp::Update { collection, .. } = w { - Some(collection) - } else { - None - } - }) - .collect(); - let delete_collections: HashSet<&Nsid> = input - .writes - .iter() - .filter_map(|w| { - if let WriteOp::Delete { collection, .. } = w { - Some(collection) - } else { - None - } - }) - .collect(); + let batch_proof = match verify_batch_write_scopes( + &auth, + &auth, + &input.writes, + |w| match w { + WriteOp::Create { collection, .. } => collection.as_str(), + WriteOp::Update { collection, .. } => collection.as_str(), + WriteOp::Delete { collection, .. } => collection.as_str(), + }, + |w| match w { + WriteOp::Create { .. } => WriteOpKind::Create, + WriteOp::Update { .. } => WriteOpKind::Update, + WriteOp::Delete { .. } => WriteOpKind::Delete, + }, + ) { + Ok(proof) => proof, + Err(e) => return Ok(e.into_response()), + }; - let scope_checks = create_collections - .iter() - .map(|c| (crate::oauth::RepoAction::Create, c)) - .chain( - update_collections - .iter() - .map(|c| (crate::oauth::RepoAction::Update, c)), - ) - .chain( - delete_collections - .iter() - .map(|c| (crate::oauth::RepoAction::Delete, c)), - ); + let principal_did = batch_proof.principal_did(); + let controller_did = batch_proof.controller_did().map(|c| c.into_did()); - if let Some(err) = scope_checks - .filter_map(|(action, collection)| { - crate::auth::scope_check::check_repo_scope( - is_oauth, - scope.as_deref(), - action, - collection, - ) - .err() - }) - .next() - { - return Ok(err); - } + if input.repo.as_str() != principal_did.as_str() { + return Err(ApiError::InvalidRepo( + "Repo does not match authenticated user".into(), + )); + } + + let did = principal_did.into_did(); + if let Err(e) = require_not_migrated(&state, &did).await { + return Ok(e); + } + if let Err(e) = require_verified_or_delegated(&state, batch_proof.user()).await { + return Ok(e); } let user_id: uuid::Uuid = state @@ -394,16 +333,16 @@ pub async fn apply_writes( .ok() .flatten() .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?; - let current_root_cid = Cid::from_str(&root_cid_str) + let current_root_cid = CommitCid::from_str(&root_cid_str) .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?; if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid) { return Err(ApiError::InvalidSwap(Some("Repo has been modified".into()))); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = tracking_store - .get(¤t_root_cid) + .get(current_root_cid.as_cid()) .await .ok() .flatten() @@ -471,7 +410,7 @@ pub async fn apply_writes( } => Some(*cid), _ => None, }); - let obsolete_cids: Vec = std::iter::once(current_root_cid) + let obsolete_cids: Vec = std::iter::once(current_root_cid.into_cid()) .chain( old_mst_blocks .keys() @@ -487,7 +426,7 @@ pub async fn apply_writes( CommitParams { did: &did, user_id, - current_root_cid: Some(current_root_cid), + current_root_cid: Some(current_root_cid.into_cid()), prev_data_cid: Some(commit.data), new_mst_root, ops, diff --git a/crates/tranquil-pds/src/api/repo/record/delete.rs b/crates/tranquil-pds/src/api/repo/record/delete.rs index 2662016..032f537 100644 --- a/crates/tranquil-pds/src/api/repo/record/delete.rs +++ b/crates/tranquil-pds/src/api/repo/record/delete.rs @@ -1,7 +1,8 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; -use crate::auth::{Active, Auth}; +use crate::auth::{Active, Auth, VerifyScope}; +use crate::cid_types::CommitCid; use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -43,32 +44,28 @@ pub async fn delete_record( auth: Auth, Json(input): Json, ) -> Result { - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { + let scope_proof = match auth.verify_repo_delete(&input.collection) { + Ok(proof) => proof, + Err(e) => return Ok(e.into_response()), + }; + + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { Ok(res) => res, Err(err_res) => return Ok(err_res), }; - if let Err(e) = crate::auth::scope_check::check_repo_scope( - repo_auth.is_oauth, - repo_auth.scope.as_deref(), - crate::oauth::RepoAction::Delete, - &input.collection, - ) { - return Ok(e); - } - let did = repo_auth.did; let user_id = repo_auth.user_id; let current_root_cid = repo_auth.current_root_cid; let controller_did = repo_auth.controller_did; if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid) { return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = match tracking_store.get(¤t_root_cid).await { + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { Ok(Some(b)) => b, _ => { return Ok( @@ -159,7 +156,7 @@ pub async fn delete_record( .into_iter() .collect(); let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); - let obsolete_cids: Vec = std::iter::once(current_root_cid) + let obsolete_cids: Vec = std::iter::once(current_root_cid.into_cid()) .chain( old_mst_blocks .keys() @@ -173,7 +170,7 @@ pub async fn delete_record( CommitParams { did: &did, user_id, - current_root_cid: Some(current_root_cid), + current_root_cid: Some(current_root_cid.into_cid()), prev_data_cid: Some(commit.data), new_mst_root, ops: vec![op], diff --git a/crates/tranquil-pds/src/api/repo/record/mod.rs b/crates/tranquil-pds/src/api/repo/record/mod.rs index b9de2a1..50e17cd 100644 --- a/crates/tranquil-pds/src/api/repo/record/mod.rs +++ b/crates/tranquil-pds/src/api/repo/record/mod.rs @@ -1,10 +1,15 @@ pub mod batch; pub mod delete; +pub mod pagination; pub mod read; pub mod utils; pub mod validation; +pub mod validation_mode; pub mod write; +pub use pagination::PaginationDirection; +pub use validation_mode::ValidationMode; + pub use batch::apply_writes; pub use delete::{DeleteRecordInput, delete_record, delete_record_internal}; pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records}; diff --git a/crates/tranquil-pds/src/api/repo/record/pagination.rs b/crates/tranquil-pds/src/api/repo/record/pagination.rs new file mode 100644 index 0000000..56cc906 --- /dev/null +++ b/crates/tranquil-pds/src/api/repo/record/pagination.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PaginationDirection { + #[default] + Forward, + Backward, +} + +impl PaginationDirection { + pub fn from_optional_bool(value: Option) -> Self { + match value { + Some(true) => Self::Backward, + Some(false) | None => Self::Forward, + } + } + + pub fn is_reverse(&self) -> bool { + matches!(self, Self::Backward) + } +} + +pub fn deserialize_pagination_direction<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(PaginationDirection::from_optional_bool(opt)) +} diff --git a/crates/tranquil-pds/src/api/repo/record/read.rs b/crates/tranquil-pds/src/api/repo/record/read.rs index 0c37b87..df39d8a 100644 --- a/crates/tranquil-pds/src/api/repo/record/read.rs +++ b/crates/tranquil-pds/src/api/repo/record/read.rs @@ -1,6 +1,8 @@ +use super::pagination::{PaginationDirection, deserialize_pagination_direction}; use crate::api::error::ApiError; use crate::state::AppState; use crate::types::{AtIdentifier, Nsid, Rkey}; +use crate::util::pds_hostname_without_port; use axum::{ Json, extract::{Query, State}, @@ -58,8 +60,7 @@ pub async fn get_record( _headers: HeaderMap, Query(input): Query, ) -> Response { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let user_id_opt = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, @@ -144,7 +145,8 @@ pub struct ListRecordsInput { pub rkey_start: Option, #[serde(rename = "rkeyEnd")] pub rkey_end: Option, - pub reverse: Option, + #[serde(default, deserialize_with = "deserialize_pagination_direction")] + pub reverse: PaginationDirection, } #[derive(Serialize)] pub struct ListRecordsOutput { @@ -157,8 +159,7 @@ pub async fn list_records( State(state): State, Query(input): Query, ) -> Response { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let user_id_opt = if input.repo.is_did() { let did: crate::types::Did = match input.repo.as_str().parse() { Ok(d) => d, @@ -194,7 +195,6 @@ pub async fn list_records( } }; let limit = input.limit.unwrap_or(50).clamp(1, 100); - let reverse = input.reverse.unwrap_or(false); let limit_i64 = limit as i64; let cursor_rkey = input .cursor @@ -207,7 +207,7 @@ pub async fn list_records( &input.collection, cursor_rkey.as_ref(), limit_i64, - reverse, + input.reverse.is_reverse(), input.rkey_start.as_ref(), input.rkey_end.as_ref(), ) diff --git a/crates/tranquil-pds/src/api/repo/record/utils.rs b/crates/tranquil-pds/src/api/repo/record/utils.rs index f6e8d07..87a6d72 100644 --- a/crates/tranquil-pds/src/api/repo/record/utils.rs +++ b/crates/tranquil-pds/src/api/repo/record/utils.rs @@ -8,6 +8,7 @@ use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; use serde_json::{Value, json}; use std::str::FromStr; +use tranquil_db_traits::SequenceNumber; use uuid::Uuid; pub fn extract_blob_cids(record: &Value) -> Vec { @@ -139,6 +140,7 @@ pub async fn commit_and_log( ) -> Result { use tranquil_db_traits::{ ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, + RepoEventType, }; let CommitParams { @@ -199,7 +201,7 @@ pub async fn commit_and_log( upserts.push(RecordUpsert { collection: collection.clone(), rkey: rkey.clone(), - cid: crate::types::CidLink::new_unchecked(cid.to_string()), + cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) }, }); } RecordOp::Delete { @@ -263,15 +265,15 @@ pub async fn commit_and_log( let commit_event = CommitEventData { did: did.clone(), - event_type: "commit".to_string(), - commit_cid: Some(crate::types::CidLink::new_unchecked( - new_root_cid.to_string(), - )), - prev_cid: current_root_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())), + event_type: RepoEventType::Commit, + commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }), + prev_cid: current_root_cid + .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), ops: Some(json!(ops_json)), blobs: Some(blobs.to_vec()), blocks_cids: Some(blocks_cids.to_vec()), - prev_data_cid: prev_data_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())), + prev_data_cid: prev_data_cid + .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), rev: Some(rev_str.clone()), }; @@ -279,8 +281,8 @@ pub async fn commit_and_log( user_id, did: did.clone(), expected_root_cid: current_root_cid - .map(|c| crate::types::CidLink::new_unchecked(c.to_string())), - new_root_cid: crate::types::CidLink::new_unchecked(new_root_cid.to_string()), + .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), + new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }, new_rev: rev_str.clone(), new_block_cids: all_block_cids, obsolete_block_cids: obsolete_bytes, @@ -417,7 +419,7 @@ pub async fn sequence_identity_event( state: &AppState, did: &Did, handle: Option<&Handle>, -) -> Result { +) -> Result { state .repo_repo .insert_identity_event(did, handle) @@ -427,12 +429,11 @@ pub async fn sequence_identity_event( pub async fn sequence_account_event( state: &AppState, did: &Did, - active: bool, - status: Option<&str>, -) -> Result { + status: tranquil_db_traits::AccountStatus, +) -> Result { state .repo_repo - .insert_account_event(did, active, status) + .insert_account_event(did, status) .await .map_err(|e| format!("DB Error (account event): {}", e)) } @@ -441,8 +442,8 @@ pub async fn sequence_sync_event( did: &Did, commit_cid: &str, rev: Option<&str>, -) -> Result { - let cid_link = crate::types::CidLink::new_unchecked(commit_cid); +) -> Result { + let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) }; state .repo_repo .insert_sync_event(did, &cid_link, rev) @@ -456,9 +457,10 @@ pub async fn sequence_genesis_commit( commit_cid: &Cid, mst_root_cid: &Cid, rev: &str, -) -> Result { - let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string()); - let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string()); +) -> Result { + let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) }; + let mst_root_cid_link = + unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) }; state .repo_repo .insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev) diff --git a/crates/tranquil-pds/src/api/repo/record/validation_mode.rs b/crates/tranquil-pds/src/api/repo/record/validation_mode.rs new file mode 100644 index 0000000..42e55f3 --- /dev/null +++ b/crates/tranquil-pds/src/api/repo/record/validation_mode.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ValidationMode { + Skip, + #[default] + Infer, + Strict, +} + +impl ValidationMode { + pub fn from_optional_bool(value: Option) -> Self { + match value { + Some(false) => Self::Skip, + Some(true) => Self::Strict, + None => Self::Infer, + } + } + + pub fn should_skip(&self) -> bool { + matches!(self, Self::Skip) + } + + pub fn requires_lexicon(&self) -> bool { + matches!(self, Self::Strict) + } +} + +pub fn deserialize_validation_mode<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(ValidationMode::from_optional_bool(opt)) +} diff --git a/crates/tranquil-pds/src/api/repo/record/write.rs b/crates/tranquil-pds/src/api/repo/record/write.rs index 968c6cb..f340ffe 100644 --- a/crates/tranquil-pds/src/api/repo/record/write.rs +++ b/crates/tranquil-pds/src/api/repo/record/write.rs @@ -1,9 +1,14 @@ use super::validation::validate_record_with_status; +use super::validation_mode::{ValidationMode, deserialize_validation_mode}; use crate::api::error::ApiError; use crate::api::repo::record::utils::{ CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, }; -use crate::auth::{Active, Auth}; +use crate::auth::{ + Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated, + require_verified_or_delegated, +}; +use crate::cid_types::CommitCid; use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -26,46 +31,31 @@ use uuid::Uuid; pub struct RepoWriteAuth { pub did: Did, pub user_id: Uuid, - pub current_root_cid: Cid, + pub current_root_cid: CommitCid, pub is_oauth: bool, pub scope: Option, pub controller_did: Option, } -pub async fn prepare_repo_write( +pub async fn prepare_repo_write( state: &AppState, - auth_user: &crate::auth::AuthenticatedUser, + scope_proof: &ScopeVerified<'_, A>, repo: &AtIdentifier, ) -> Result { - if repo.as_str() != auth_user.did.as_str() { + let user = scope_proof.user(); + let principal_did = scope_proof.principal_did(); + if repo.as_str() != principal_did.as_str() { return Err( ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), ); } - if state - .user_repo - .is_account_migrated(&auth_user.did) - .await - .unwrap_or(false) - { - return Err(ApiError::AccountMigrated.into_response()); - } - let is_verified = state - .user_repo - .has_verified_comms_channel(&auth_user.did) - .await - .unwrap_or(false); - let is_delegated = state - .delegation_repo - .is_delegated_account(&auth_user.did) - .await - .unwrap_or(false); - if !is_verified && !is_delegated { - return Err(ApiError::AccountNotVerified.into_response()); - } + + require_not_migrated(state, principal_did.as_did()).await?; + let _account_verified = require_verified_or_delegated(state, user).await?; + let user_id = state .user_repo - .get_id_by_did(&auth_user.did) + .get_id_by_did(principal_did.as_did()) .await .map_err(|e| { error!("DB error fetching user: {}", e); @@ -83,16 +73,16 @@ pub async fn prepare_repo_write( .ok_or_else(|| { ApiError::InternalError(Some("Repo root not found".into())).into_response() })?; - let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { + let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| { ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() })?; Ok(RepoWriteAuth { - did: auth_user.did.clone(), + did: principal_did.into_did(), user_id, current_root_cid, - is_oauth: auth_user.is_oauth(), - scope: auth_user.scope.clone(), - controller_did: auth_user.controller_did.clone(), + is_oauth: user.is_oauth(), + scope: user.scope.clone(), + controller_did: scope_proof.controller_did().map(|c| c.into_did()), }) } #[derive(Deserialize)] @@ -101,7 +91,8 @@ pub struct CreateRecordInput { pub repo: AtIdentifier, pub collection: Nsid, pub rkey: Option, - pub validate: Option, + #[serde(default, deserialize_with = "deserialize_validation_mode")] + pub validate: ValidationMode, pub record: serde_json::Value, #[serde(rename = "swapCommit")] pub swap_commit: Option, @@ -127,40 +118,35 @@ pub async fn create_record( auth: Auth, Json(input): Json, ) -> Result { - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { + let scope_proof = match auth.verify_repo_create(&input.collection) { + Ok(proof) => proof, + Err(e) => return Ok(e.into_response()), + }; + + let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await { Ok(res) => res, Err(err_res) => return Ok(err_res), }; - if let Err(e) = crate::auth::scope_check::check_repo_scope( - repo_auth.is_oauth, - repo_auth.scope.as_deref(), - crate::oauth::RepoAction::Create, - &input.collection, - ) { - return Ok(e); - } - let did = repo_auth.did; let user_id = repo_auth.user_id; let current_root_cid = repo_auth.current_root_cid; let controller_did = repo_auth.controller_did; if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid) { return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); } - let validation_status = if input.validate == Some(false) { + let validation_status = if input.validate.should_skip() { None } else { - let require_lexicon = input.validate == Some(true); match validate_record_with_status( &input.record, &input.collection, input.rkey.as_ref(), - require_lexicon, + input.validate.requires_lexicon(), ) { Ok(status) => Some(status), Err(err_response) => return Ok(*err_response), @@ -169,7 +155,7 @@ pub async fn create_record( let rkey = input.rkey.unwrap_or_else(Rkey::generate); let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = match tracking_store.get(¤t_root_cid).await { + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { Ok(Some(b)) => b, _ => { return Ok( @@ -192,7 +178,7 @@ pub async fn create_record( let mut conflict_uris_to_cleanup: Vec = Vec::new(); let mut all_old_mst_blocks = std::collections::BTreeMap::new(); - if input.validate != Some(false) { + if !input.validate.should_skip() { let record_uri = AtUri::from_parts(&did, &input.collection, &rkey); let backlinks = extract_backlinks(&record_uri, &input.record); @@ -323,7 +309,7 @@ pub async fn create_record( .collect(); let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); let blob_cids = extract_blob_cids(&input.record); - let obsolete_cids: Vec = std::iter::once(current_root_cid) + let obsolete_cids: Vec = std::iter::once(current_root_cid.into_cid()) .chain( all_old_mst_blocks .keys() @@ -337,7 +323,7 @@ pub async fn create_record( CommitParams { did: &did, user_id, - current_root_cid: Some(current_root_cid), + current_root_cid: Some(current_root_cid.into_cid()), prev_data_cid: Some(initial_mst_root), new_mst_root, ops, @@ -412,7 +398,8 @@ pub struct PutRecordInput { pub repo: AtIdentifier, pub collection: Nsid, pub rkey: Rkey, - pub validate: Option, + #[serde(default, deserialize_with = "deserialize_validation_mode")] + pub validate: ValidationMode, pub record: serde_json::Value, #[serde(rename = "swapCommit")] pub swap_commit: Option, @@ -434,40 +421,28 @@ pub async fn put_record( auth: Auth, Json(input): Json, ) -> Result { - let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await { + let upsert_proof = match auth.verify_repo_upsert(&input.collection) { + Ok(proof) => proof, + Err(e) => return Ok(e.into_response()), + }; + + let repo_auth = match prepare_repo_write(&state, &upsert_proof, &input.repo).await { Ok(res) => res, Err(err_res) => return Ok(err_res), }; - if let Err(e) = crate::auth::scope_check::check_repo_scope( - repo_auth.is_oauth, - repo_auth.scope.as_deref(), - crate::oauth::RepoAction::Create, - &input.collection, - ) { - return Ok(e); - } - if let Err(e) = crate::auth::scope_check::check_repo_scope( - repo_auth.is_oauth, - repo_auth.scope.as_deref(), - crate::oauth::RepoAction::Update, - &input.collection, - ) { - return Ok(e); - } - let did = repo_auth.did; let user_id = repo_auth.user_id; let current_root_cid = repo_auth.current_root_cid; let controller_did = repo_auth.controller_did; if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + && CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid) { return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = match tracking_store.get(¤t_root_cid).await { + let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await { Ok(Some(b)) => b, _ => { return Ok( @@ -485,15 +460,14 @@ pub async fn put_record( }; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let key = format!("{}/{}", input.collection, input.rkey); - let validation_status = if input.validate == Some(false) { + let validation_status = if input.validate.should_skip() { None } else { - let require_lexicon = input.validate == Some(true); match validate_record_with_status( &input.record, &input.collection, Some(&input.rkey), - require_lexicon, + input.validate.requires_lexicon(), ) { Ok(status) => Some(status), Err(err_response) => return Ok(*err_response), @@ -610,7 +584,7 @@ pub async fn put_record( let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); let is_update = existing_cid.is_some(); let blob_cids = extract_blob_cids(&input.record); - let obsolete_cids: Vec = std::iter::once(current_root_cid) + let obsolete_cids: Vec = std::iter::once(current_root_cid.into_cid()) .chain( old_mst_blocks .keys() @@ -624,7 +598,7 @@ pub async fn put_record( CommitParams { did: &did, user_id, - current_root_cid: Some(current_root_cid), + current_root_cid: Some(current_root_cid.into_cid()), prev_data_cid: Some(commit.data), new_mst_root, ops: vec![op], diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 75eca55..289fc27 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -1,10 +1,11 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; -use crate::auth::{Auth, NotTakendown, Permissive}; +use crate::api::error::{ApiError, DbResultExt}; +use crate::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa}; use crate::cache::Cache; use crate::plc::PlcClient; use crate::state::AppState; use crate::types::PlainPassword; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -130,7 +131,7 @@ async fn assert_valid_did_document_for_service( did: &crate::types::Did, with_retry: bool, ) -> Result<(), ApiError> { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let expected_endpoint = format!("https://{}", hostname); if did.as_str().starts_with("did:plc:") { @@ -219,10 +220,10 @@ async fn assert_valid_did_document_for_service( .and_then(|v| v.get("atproto")) .and_then(|k| k.as_str()); - let user_key = user_repo.get_user_key_by_did(did).await.map_err(|e| { - error!("Failed to fetch user key: {:?}", e); - ApiError::InternalError(None) - })?; + let user_key = user_repo + .get_user_key_by_did(did) + .await + .log_db_err("fetching user key")?; if let Some(key_info) = user_key { let key_bytes = @@ -379,8 +380,12 @@ pub async fn activate_account( "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", did ); - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did, + tranquil_db_traits::AccountStatus::Active, + ) + .await { warn!( "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", @@ -502,8 +507,7 @@ pub async fn deactivate_account( if let Err(e) = crate::api::repo::record::sequence_account_event( &state, &did, - false, - Some("deactivated"), + tranquil_db_traits::AccountStatus::Deactivated, ) .await { @@ -523,20 +527,14 @@ pub async fn request_account_delete( State(state): State, auth: Auth, ) -> Result { - let did = &auth.did; - - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - did, - ) - .await); - } + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; let user_id = state .user_repo - .get_id_by_did(did) + .get_id_by_did(session_mfa.did()) .await .ok() .flatten() @@ -545,25 +543,22 @@ pub async fn request_account_delete( let expires_at = Utc::now() + Duration::minutes(15); state .infra_repo - .create_deletion_request(&confirmation_token, did, expires_at) + .create_deletion_request(&confirmation_token, session_mfa.did(), expires_at) .await - .map_err(|e| { - error!("DB error creating deletion token: {:?}", e); - ApiError::InternalError(None) - })?; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + .log_db_err("creating deletion token")?; + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, &confirmation_token, - &hostname, + hostname, ) .await { warn!("Failed to enqueue account deletion notification: {:?}", e); } - info!("Account deletion requested for user {}", did); + info!("Account deletion requested for user {}", session_mfa.did()); Ok(EmptyResponse::ok().into_response()) } @@ -642,8 +637,12 @@ pub async fn delete_account( error!("DB error deleting account: {:?}", e); return ApiError::InternalError(None).into_response(); } - let account_seq = - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await; + let account_seq = crate::api::repo::record::sequence_account_event( + &state, + did, + tranquil_db_traits::AccountStatus::Deleted, + ) + .await; match account_seq { Ok(seq) => { if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await { diff --git a/crates/tranquil-pds/src/api/server/app_password.rs b/crates/tranquil-pds/src/api/server/app_password.rs index a2a9010..93a9e70 100644 --- a/crates/tranquil-pds/src/api/server/app_password.rs +++ b/crates/tranquil-pds/src/api/server/app_password.rs @@ -1,17 +1,17 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Auth, NotTakendown, Permissive, generate_app_password}; use crate::delegation::{DelegationActionType, intersect_scopes}; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{AppPasswordLimit, RateLimited}; +use crate::state::AppState; use axum::{ Json, extract::State, - http::HeaderMap, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use serde_json::json; -use tracing::{error, warn}; +use tracing::error; use tranquil_db_traits::AppPasswordCreate; #[derive(Serialize)] @@ -39,26 +39,20 @@ pub async fn list_app_passwords( .user_repo .get_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error getting user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let rows = state .session_repo .list_app_passwords(user.id) .await - .map_err(|e| { - error!("DB error listing app passwords: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("listing app passwords")?; let passwords: Vec = rows .iter() .map(|row| AppPassword { name: row.name.clone(), created_at: row.created_at.to_rfc3339(), - privileged: row.privileged, + privileged: row.privilege.is_privileged(), scopes: row.scopes.clone(), created_by_controller: row .created_by_controller_did @@ -89,27 +83,15 @@ pub struct CreateAppPasswordOutput { pub async fn create_app_password( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, auth: Auth, Json(input): Json, ) -> Result { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::AppPassword, &client_ip) - .await - { - warn!(ip = %client_ip, "App password creation rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - let user = state .user_repo .get_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error getting user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let name = input.name.trim(); @@ -121,10 +103,7 @@ pub async fn create_app_password( .session_repo .get_app_password_by_name(user.id, name) .await - .map_err(|e| { - error!("DB error checking app password: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("checking app password")? .is_some() { return Err(ApiError::DuplicateAppPassword); @@ -140,7 +119,7 @@ pub async fn create_app_password( let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); let requested = input.scopes.as_deref().unwrap_or("atproto"); - let intersected = intersect_scopes(requested, &granted_scopes); + let intersected = intersect_scopes(requested, granted_scopes.as_str()); if intersected.is_empty() && !granted_scopes.is_empty() { return Err(ApiError::InsufficientScope(None)); @@ -171,14 +150,15 @@ pub async fn create_app_password( ApiError::InternalError(None) })?; - let privileged = input.privileged.unwrap_or(false); + let privilege = + tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false)); let created_at = chrono::Utc::now(); let create_data = AppPasswordCreate { user_id: user.id, name: name.to_string(), password_hash, - privileged, + privilege, scopes: final_scopes.clone(), created_by_controller_did: controller_did.clone(), }; @@ -187,10 +167,7 @@ pub async fn create_app_password( .session_repo .create_app_password(&create_data) .await - .map_err(|e| { - error!("DB error creating app password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("creating app password")?; if let Some(ref controller) = controller_did { let _ = state @@ -214,7 +191,7 @@ pub async fn create_app_password( name: name.to_string(), password, created_at: created_at.to_rfc3339(), - privileged, + privileged: privilege.is_privileged(), scopes: final_scopes, }) .into_response()) @@ -234,10 +211,7 @@ pub async fn revoke_app_password( .user_repo .get_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error getting user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let name = input.name.trim(); @@ -255,10 +229,7 @@ pub async fn revoke_app_password( .session_repo .delete_sessions_by_app_password(&auth.did, name) .await - .map_err(|e| { - error!("DB error revoking sessions for app password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking sessions for app password")?; futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { let cache_key = format!("auth:session:{}:{}", &auth.did, jti); @@ -273,10 +244,7 @@ pub async fn revoke_app_password( .session_repo .delete_app_password(user.id, name) .await - .map_err(|e| { - error!("DB error revoking app password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking app password")?; Ok(EmptyResponse::ok().into_response()) } diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index bf3f118..11688b0 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -1,7 +1,9 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse}; use crate::auth::{Auth, NotTakendown}; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit}; +use crate::state::AppState; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -44,19 +46,10 @@ pub struct RequestEmailUpdateInput { pub async fn request_email_update( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, auth: Auth, input: Option>, ) -> Result { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) - .await - { - warn!(ip = %client_ip, "Email update rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - if let Err(e) = crate::auth::scope_check::check_account_scope( auth.is_oauth(), auth.scope.as_deref(), @@ -70,10 +63,7 @@ pub async fn request_email_update( .user_repo .get_email_info_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting email info")? .ok_or(ApiError::AccountNotFound)?; let Some(current_email) = user.email else { @@ -111,14 +101,14 @@ pub async fn request_email_update( } } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( state.user_repo.as_ref(), state.infra_repo.as_ref(), user.id, &code, &formatted_code, - &hostname, + hostname, ) .await { @@ -139,19 +129,10 @@ pub struct ConfirmEmailInput { pub async fn confirm_email( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, auth: Auth, Json(input): Json, ) -> Result { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::EmailUpdate, &client_ip) - .await - { - warn!(ip = %client_ip, "Confirm email rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - if let Err(e) = crate::auth::scope_check::check_account_scope( auth.is_oauth(), auth.scope.as_deref(), @@ -166,10 +147,7 @@ pub async fn confirm_email( .user_repo .get_email_info_by_did(did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting email info")? .ok_or(ApiError::AccountNotFound)?; let Some(ref email) = user.email else { @@ -213,10 +191,7 @@ pub async fn confirm_email( .user_repo .set_email_verified(user.id, true) .await - .map_err(|e| { - error!("DB error confirming email: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("confirming email")?; info!("Email confirmed for user {}", user.id); Ok(EmptyResponse::ok().into_response()) @@ -250,10 +225,7 @@ pub async fn update_email( .user_repo .get_email_info_by_did(did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting email info")? .ok_or(ApiError::AccountNotFound)?; let user_id = user.id; @@ -325,23 +297,20 @@ pub async fn update_email( .user_repo .update_email(user_id, &new_email) .await - .map_err(|e| { - error!("DB error updating email: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating email")?; let verification_token = crate::auth::verification_token::generate_signup_token(did, "email", &new_email); let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( state.infra_repo.as_ref(), user_id, "email", &new_email, &formatted_token, - &hostname, + hostname, ) .await { @@ -371,17 +340,9 @@ pub struct CheckEmailVerifiedInput { pub async fn check_email_verified( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) - .await - { - return ApiError::RateLimitExceeded(None).into_response(); - } - match state .user_repo .check_email_verified_by_identifier(&input.identifier) @@ -403,17 +364,9 @@ pub struct AuthorizeEmailUpdateQuery { pub async fn authorize_email_update( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, axum::extract::Query(query): axum::extract::Query, ) -> Response { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) - .await - { - return ApiError::RateLimitExceeded(None).into_response(); - } - let verified = crate::auth::verification_token::verify_token_signature(&query.token); let token_data = match verified { @@ -488,7 +441,7 @@ pub async fn authorize_email_update( info!(did = %did, "Email update authorized via link click"); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let redirect_url = format!( "https://{}/app/verify?type=email-authorize-success", hostname @@ -499,17 +452,9 @@ pub async fn authorize_email_update( pub async fn check_email_update_status( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, auth: Auth, ) -> Result { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) - .await - { - return Err(ApiError::RateLimitExceeded(None)); - } - if let Err(e) = crate::auth::scope_check::check_account_scope( auth.is_oauth(), auth.scope.as_deref(), @@ -549,17 +494,9 @@ pub struct CheckEmailInUseInput { pub async fn check_email_in_use( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) - .await - { - return ApiError::RateLimitExceeded(None).into_response(); - } - let email = input.email.trim().to_lowercase(); if email.is_empty() { return ApiError::InvalidRequest("email is required".into()).into_response(); @@ -587,17 +524,9 @@ pub struct CheckCommsChannelInUseInput { pub async fn check_comms_channel_in_use( State(state): State, - headers: axum::http::HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) - .await - { - return ApiError::RateLimitExceeded(None).into_response(); - } - let channel = match input.channel.to_lowercase().as_str() { "email" => CommsChannel::Email, "discord" => CommsChannel::Discord, diff --git a/crates/tranquil-pds/src/api/server/invite.rs b/crates/tranquil-pds/src/api/server/invite.rs index 1ff6f42..4319eaa 100644 --- a/crates/tranquil-pds/src/api/server/invite.rs +++ b/crates/tranquil-pds/src/api/server/invite.rs @@ -1,7 +1,9 @@ use crate::api::ApiError; +use crate::api::error::DbResultExt; use crate::auth::{Admin, Auth, NotTakendown}; use crate::state::AppState; use crate::types::Did; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -24,7 +26,7 @@ fn gen_random_token() -> String { } fn gen_invite_code() -> String { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let hostname_prefix = hostname.replace('.', "-"); format!("{}-{}", hostname_prefix, gen_random_token()) } @@ -121,10 +123,7 @@ pub async fn create_invite_codes( .user_repo .get_any_admin_user_id() .await - .map_err(|e| { - error!("DB error looking up admin user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("looking up admin user")? .ok_or_else(|| { error!("No admin user found to create invite codes"); ApiError::InternalError(None) @@ -202,14 +201,11 @@ pub async fn get_account_invite_codes( .infra_repo .get_invite_codes_for_account(&auth.did) .await - .map_err(|e| { - error!("DB error fetching invite codes: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching invite codes")?; let filtered_codes: Vec<_> = codes_info .into_iter() - .filter(|info| !info.disabled) + .filter(|info| info.state.is_active()) .collect(); let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| { diff --git a/crates/tranquil-pds/src/api/server/logo.rs b/crates/tranquil-pds/src/api/server/logo.rs index e086d7b..de50be5 100644 --- a/crates/tranquil-pds/src/api/server/logo.rs +++ b/crates/tranquil-pds/src/api/server/logo.rs @@ -21,7 +21,7 @@ pub async fn get_logo(State(state): State) -> Response { Some(c) if !c.is_empty() => c, _ => return StatusCode::NOT_FOUND.into_response(), }; - let cid = crate::types::CidLink::new_unchecked(&cid_str); + let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) }; let metadata = match state.blob_repo.get_blob_metadata(&cid).await { Ok(Some(m)) => m, diff --git a/crates/tranquil-pds/src/api/server/meta.rs b/crates/tranquil-pds/src/api/server/meta.rs index 2a97ea7..f48a8ce 100644 --- a/crates/tranquil-pds/src/api/server/meta.rs +++ b/crates/tranquil-pds/src/api/server/meta.rs @@ -1,4 +1,5 @@ use crate::state::AppState; +use crate::util::pds_hostname; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde_json::json; @@ -30,9 +31,9 @@ pub fn is_self_hosted_did_web_enabled() -> bool { } pub async fn describe_server() -> impl IntoResponse { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let domains_str = - std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone()); + std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string()); let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") diff --git a/crates/tranquil-pds/src/api/server/migration.rs b/crates/tranquil-pds/src/api/server/migration.rs index 0eaec3f..55e2f59 100644 --- a/crates/tranquil-pds/src/api/server/migration.rs +++ b/crates/tranquil-pds/src/api/server/migration.rs @@ -1,6 +1,8 @@ use crate::api::ApiError; +use crate::api::error::DbResultExt; use crate::auth::{Active, Auth}; use crate::state::AppState; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -49,10 +51,7 @@ pub async fn update_did_document( .user_repo .get_user_for_did_doc(&auth.did) .await - .map_err(|e| { - tracing::error!("DB error getting user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; if let Some(ref methods) = input.verification_methods { @@ -107,10 +106,7 @@ pub async fn update_did_document( .user_repo .upsert_did_web_overrides(user.id, verification_methods_json, also_known_as) .await - .map_err(|e| { - tracing::error!("DB error upserting did_web_overrides: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("upserting did_web_overrides")?; if let Some(ref endpoint) = input.service_endpoint { let endpoint_clean = endpoint.trim().trim_end_matches('/'); @@ -118,10 +114,7 @@ pub async fn update_did_document( .user_repo .update_migrated_to_pds(&auth.did, endpoint_clean) .await - .map_err(|e| { - tracing::error!("DB error updating service endpoint: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating service endpoint")?; } let did_doc = build_did_document(&state, &auth.did).await; @@ -154,7 +147,7 @@ pub async fn get_did_document( } async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let user = match state.user_repo.get_user_for_did_doc_build(did).await { Ok(Some(row)) => row, diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index 40649e1..38312d8 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -1,5 +1,6 @@ use crate::api::SuccessResponse; use crate::api::error::ApiError; +use crate::auth::NormalizedLoginIdentifier; use axum::{ Json, extract::State, @@ -19,25 +20,12 @@ use uuid::Uuid; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; +use crate::state::AppState; use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; +use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "unknown".to_string() -} - fn generate_setup_token() -> String { let mut rng = rand::thread_rng(); (0..32) @@ -80,23 +68,12 @@ pub struct CreatePasskeyAccountResponse { pub async fn create_passkey_account( State(state): State, + _rate_limit: RateLimited, headers: HeaderMap, Json(input): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) - .await - { - warn!(ip = %client_ip, "Account creation rate limit exceeded"); - return ApiError::RateLimitExceeded(Some( - "Too many account creation attempts. Please try again later.".into(), - )) - .into_response(); - } - let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), + crate::util::get_header_str(&headers, "Authorization"), ) { let token = extracted.token; if is_service_token(&token) { @@ -135,8 +112,8 @@ pub async fn create_passkey_account( .map(|d| d.starts_with("did:web:")) .unwrap_or(false); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); let pds_suffix = format!(".{}", hostname_for_handles); let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { @@ -169,15 +146,10 @@ pub async fn create_passkey_account( return ApiError::InvalidEmail.into_response(); } - if let Some(ref code) = input.invite_code { - let valid = state - .infra_repo - .is_invite_code_valid(code) - .await - .unwrap_or(false); - - if !valid { - return ApiError::InvalidInviteCode.into_response(); + let _validated_invite_code = if let Some(ref code) = input.invite_code { + match state.infra_repo.validate_invite_code(code).await { + Ok(validated) => Some(validated), + Err(_) => return ApiError::InvalidInviteCode.into_response(), } } else { let invite_required = std::env::var("INVITE_CODE_REQUIRED") @@ -186,7 +158,8 @@ pub async fn create_passkey_account( if invite_required { return ApiError::InviteCodeRequired.into_response(); } - } + None + }; let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); let verification_recipient = match verification_channel { @@ -268,7 +241,7 @@ pub async fn create_passkey_account( } if is_byod_did_web { if let Some(ref auth_did) = byod_auth - && d != auth_did + && d != auth_did.as_str() { return ApiError::AuthorizationError(format!( "Service token issuer {} does not match DID {}", @@ -280,7 +253,7 @@ pub async fn create_passkey_account( } else { if let Err(e) = crate::api::identity::did::verify_did_web( d, - &hostname, + hostname, &input.handle, input.signing_key.as_deref(), ) @@ -296,7 +269,7 @@ pub async fn create_passkey_account( if let Some(ref auth_did) = byod_auth { if let Some(ref provided_did) = input.did { if provided_did.starts_with("did:plc:") { - if provided_did != auth_did { + if provided_did != auth_did.as_str() { return ApiError::AuthorizationError(format!( "Service token issuer {} does not match DID {}", auth_did, provided_did @@ -389,7 +362,7 @@ pub async fn create_passkey_account( } }; let rev = Tid::now(LimitedU32::MIN); - let did_typed = Did::new_unchecked(&did); + let did_typed = unsafe { Did::new_unchecked(&did) }; let (commit_bytes, _sig) = match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) { Ok(result) => result, @@ -422,7 +395,7 @@ pub async fn create_passkey_account( _ => tranquil_db_traits::CommsChannel::Email, }; - let handle_typed = Handle::new_unchecked(&handle); + let handle_typed = unsafe { Handle::new_unchecked(&handle) }; let create_input = tranquil_db_traits::CreatePasskeyAccountInput { handle: handle_typed.clone(), email: email.clone().unwrap_or_default(), @@ -484,8 +457,12 @@ pub async fn create_passkey_account( { warn!("Failed to sequence identity event for {}: {}", did, e); } - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did_typed, + tranquil_db_traits::AccountStatus::Active, + ) + .await { warn!("Failed to sequence account event for {}: {}", did, e); } @@ -493,8 +470,8 @@ pub async fn create_passkey_account( "$type": "app.bsky.actor.profile", "displayName": handle }); - let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile"); - let profile_rkey = Rkey::new_unchecked("self"); + let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; + let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did_typed, @@ -521,7 +498,7 @@ pub async fn create_passkey_account( verification_channel, &verification_recipient, &formatted_token, - &hostname, + hostname, ) .await { @@ -541,7 +518,7 @@ pub async fn create_passkey_account( refresh_jti, access_expires_at: token_meta.expires_at, refresh_expires_at: refresh_expires, - legacy_login: false, + login_type: tranquil_db::LoginType::Modern, mfa_verified: false, scope: None, controller_did: None, @@ -626,14 +603,7 @@ pub async fn complete_passkey_setup( return ApiError::InvalidToken(None).into_response(); } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - error!("Failed to create WebAuthn config: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let webauthn = &state.webauthn_config; let reg_state = match state .user_repo @@ -768,14 +738,7 @@ pub async fn start_passkey_registration_for_setup( return ApiError::InvalidToken(None).into_response(); } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - error!("Failed to create WebAuthn config: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let webauthn = &state.webauthn_config; let existing_passkeys = state .user_repo @@ -840,30 +803,18 @@ pub struct RequestPasskeyRecoveryInput { pub async fn request_passkey_recovery( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) - .await - { - return ApiError::RateLimitExceeded(None).into_response(); - } - - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); + let hostname_for_handles = pds_hostname_without_port(); let identifier = input.email.trim().to_lowercase(); let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); - let normalized_handle = if identifier.contains('@') || identifier.contains('.') { - identifier.to_string() - } else { - format!("{}.{}", identifier, hostname_for_handles) - }; + let normalized_handle = + NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles); let user = match state .user_repo - .get_user_for_passkey_recovery(identifier, &normalized_handle) + .get_user_for_passkey_recovery(identifier, normalized_handle.as_str()) .await { Ok(Some(u)) if !u.password_required => u, @@ -890,7 +841,7 @@ pub async fn request_passkey_recovery( return ApiError::InternalError(None).into_response(); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let recovery_url = format!( "https://{}/app/recover-passkey?did={}&token={}", hostname, @@ -903,7 +854,7 @@ pub async fn request_passkey_recovery( state.infra_repo.as_ref(), user.id, &recovery_url, - &hostname, + hostname, ) .await; diff --git a/crates/tranquil-pds/src/api/server/passkeys.rs b/crates/tranquil-pds/src/api/server/passkeys.rs index 3b80511..7113cc1 100644 --- a/crates/tranquil-pds/src/api/server/passkeys.rs +++ b/crates/tranquil-pds/src/api/server/passkeys.rs @@ -1,7 +1,6 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; -use crate::auth::webauthn::WebAuthnConfig; -use crate::auth::{Active, Auth}; +use crate::api::error::{ApiError, DbResultExt}; +use crate::auth::{Active, Auth, require_legacy_session_mfa, require_reauth_window}; use crate::state::AppState; use axum::{ Json, @@ -12,14 +11,6 @@ use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use webauthn_rs::prelude::*; -fn get_webauthn() -> Result { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - WebAuthnConfig::new(&hostname).map_err(|e| { - error!("Failed to create WebAuthn config: {}", e); - ApiError::InternalError(Some("WebAuthn configuration failed".into())) - }) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartRegistrationInput { @@ -37,26 +28,20 @@ pub async fn start_passkey_registration( auth: Auth, Json(input): Json, ) -> Result { - let webauthn = get_webauthn()?; + let webauthn = &state.webauthn_config; let handle = state .user_repo .get_handle_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error fetching user: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching user")? .ok_or(ApiError::AccountNotFound)?; let existing_passkeys = state .user_repo .get_passkeys_for_user(&auth.did) .await - .map_err(|e| { - error!("DB error fetching existing passkeys: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching existing passkeys")?; let exclude_credentials: Vec = existing_passkeys .iter() @@ -81,10 +66,7 @@ pub async fn start_passkey_registration( .user_repo .save_webauthn_challenge(&auth.did, "registration", &state_json) .await - .map_err(|e| { - error!("Failed to save registration state: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("saving registration state")?; let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({})); @@ -112,16 +94,13 @@ pub async fn finish_passkey_registration( auth: Auth, Json(input): Json, ) -> Result { - let webauthn = get_webauthn()?; + let webauthn = &state.webauthn_config; let reg_state_json = state .user_repo .load_webauthn_challenge(&auth.did, "registration") .await - .map_err(|e| { - error!("DB error loading registration state: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("loading registration state")? .ok_or(ApiError::NoRegistrationInProgress)?; let reg_state: SecurityKeyRegistration = @@ -157,10 +136,7 @@ pub async fn finish_passkey_registration( input.friendly_name.as_deref(), ) .await - .map_err(|e| { - error!("Failed to save passkey: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("saving passkey")?; if let Err(e) = state .user_repo @@ -208,10 +184,7 @@ pub async fn list_passkeys( .user_repo .get_passkeys_for_user(&auth.did) .await - .map_err(|e| { - error!("DB error fetching passkeys: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching passkeys")?; let passkey_infos: Vec = passkeys .into_iter() @@ -241,30 +214,21 @@ pub async fn delete_passkey( auth: Auth, Json(input): Json, ) -> Result { - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await - { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; - if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await { - return Ok(crate::api::server::reauth::reauth_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let reauth_mfa = match require_reauth_window(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; - match state.user_repo.delete_passkey(id, &auth.did).await { + match state.user_repo.delete_passkey(id, reauth_mfa.did()).await { Ok(true) => { - info!(did = %auth.did, passkey_id = %id, "Passkey deleted"); + info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted"); Ok(EmptyResponse::ok().into_response()) } Ok(false) => Err(ApiError::PasskeyNotFound), diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 5fc78bf..2c4f09f 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -1,16 +1,20 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; -use crate::auth::{Active, Auth}; -use crate::state::{AppState, RateLimitKind}; +use crate::auth::{ + Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window, + require_reauth_window_if_available, +}; +use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit}; +use crate::state::AppState; use crate::types::PlainPassword; +use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; use axum::{ Json, extract::State, - http::HeaderMap, response::{IntoResponse, Response}, }; -use bcrypt::{DEFAULT_COST, hash, verify}; +use bcrypt::{DEFAULT_COST, hash}; use chrono::{Duration, Utc}; use serde::Deserialize; use tracing::{error, info, warn}; @@ -18,20 +22,6 @@ use tracing::{error, info, warn}; fn generate_reset_code() -> String { crate::util::generate_token_code() } -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "unknown".to_string() -} #[derive(Deserialize)] pub struct RequestPasswordResetInput { @@ -41,31 +31,18 @@ pub struct RequestPasswordResetInput { pub async fn request_password_reset( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) - .await - { - warn!(ip = %client_ip, "Password reset rate limit exceeded"); - return ApiError::RateLimitExceeded(None).into_response(); - } let identifier = input.email.trim(); if identifier.is_empty() { return ApiError::InvalidRequest("email or handle is required".into()).into_response(); } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); + let hostname_for_handles = pds_hostname_without_port(); let normalized = identifier.to_lowercase(); let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); let is_email_lookup = normalized.contains('@'); - let normalized_handle = if normalized.contains('@') || normalized.contains('.') { - normalized.to_string() - } else { - format!("{}.{}", normalized, hostname_for_handles) - }; + let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles); let multiple_accounts_warning = if is_email_lookup { match state.user_repo.count_accounts_by_email(normalized).await { @@ -78,7 +55,7 @@ pub async fn request_password_reset( let user_id = match state .user_repo - .get_id_by_email_or_handle(normalized, &normalized_handle) + .get_id_by_email_or_handle(normalized, normalized_handle.as_str()) .await { Ok(Some(id)) => id, @@ -101,13 +78,13 @@ pub async fn request_password_reset( error!("DB error setting reset code: {:?}", e); return ApiError::InternalError(None).into_response(); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_password_reset( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, &code, - &hostname, + hostname, ) .await { @@ -135,17 +112,9 @@ pub struct ResetPasswordInput { pub async fn reset_password( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::ResetPassword, &client_ip) - .await - { - warn!(ip = %client_ip, "Reset password rate limit exceeded"); - return ApiError::RateLimitExceeded(None).into_response(); - } let token = input.token.trim(); let password = &input.password; if token.is_empty() { @@ -230,50 +199,35 @@ pub async fn change_password( auth: Auth, Json(input): Json, ) -> Result { - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await - { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + use crate::auth::verify_password_mfa; - let current_password = &input.current_password; - let new_password = &input.new_password; - if current_password.is_empty() { + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; + + if input.current_password.is_empty() { return Err(ApiError::InvalidRequest( "currentPassword is required".into(), )); } - if new_password.is_empty() { + if input.new_password.is_empty() { return Err(ApiError::InvalidRequest("newPassword is required".into())); } - if let Err(e) = validate_password(new_password) { + if let Err(e) = validate_password(&input.new_password) { return Err(ApiError::InvalidRequest(e.to_string())); } + + let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?; + let user = state .user_repo - .get_id_and_password_hash_by_did(&auth.did) + .get_id_and_password_hash_by_did(password_mfa.did()) .await - .map_err(|e| { - error!("DB error in change_password: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("in change_password")? .ok_or(ApiError::AccountNotFound)?; - let (user_id, password_hash) = (user.id, user.password_hash); - let valid = verify(current_password, &password_hash).map_err(|e| { - error!("Password verification error: {:?}", e); - ApiError::InternalError(None) - })?; - if !valid { - return Err(ApiError::InvalidPassword( - "Current password is incorrect".into(), - )); - } - let new_password_clone = new_password.to_string(); + let new_password_clone = input.new_password.to_string(); let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)) .await .map_err(|e| { @@ -287,14 +241,11 @@ pub async fn change_password( state .user_repo - .update_password_hash(user_id, &new_hash) + .update_password_hash(user.id, &new_hash) .await - .map_err(|e| { - error!("DB error updating password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating password")?; - info!(did = %&auth.did, "Password changed successfully"); + info!(did = %session_mfa.did(), "Password changed successfully"); Ok(EmptyResponse::ok().into_response()) } @@ -302,48 +253,32 @@ pub async fn get_password_status( State(state): State, auth: Auth, ) -> Result { - match state.user_repo.has_password_by_did(&auth.did).await { - Ok(Some(has)) => Ok(HasPasswordResponse::response(has).into_response()), - Ok(None) => Err(ApiError::AccountNotFound), - Err(e) => { - error!("DB error: {:?}", e); - Err(ApiError::InternalError(None)) - } - } + let has = state + .user_repo + .has_password_by_did(&auth.did) + .await + .log_db_err("checking password status")? + .ok_or(ApiError::AccountNotFound)?; + Ok(HasPasswordResponse::response(has).into_response()) } pub async fn remove_password( State(state): State, auth: Auth, ) -> Result { - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await - { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; - if crate::api::server::reauth::check_reauth_required_cached( - &*state.session_repo, - &state.cache, - &auth.did, - ) - .await - { - return Ok(crate::api::server::reauth::reauth_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let reauth_mfa = match require_reauth_window(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; let has_passkeys = state .user_repo - .has_passkeys(&auth.did) + .has_passkeys(reauth_mfa.did()) .await .unwrap_or(false); if !has_passkeys { @@ -354,12 +289,9 @@ pub async fn remove_password( let user = state .user_repo - .get_password_info_by_did(&auth.did) + .get_password_info_by_did(reauth_mfa.did()) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting password info")? .ok_or(ApiError::AccountNotFound)?; if user.password_hash.is_none() { @@ -372,12 +304,9 @@ pub async fn remove_password( .user_repo .remove_user_password(user.id) .await - .map_err(|e| { - error!("DB error removing password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("removing password")?; - info!(did = %&auth.did, "Password removed - account is now passkey-only"); + info!(did = %session_mfa.did(), "Password removed - account is now passkey-only"); Ok(SuccessResponse::ok().into_response()) } @@ -392,41 +321,10 @@ pub async fn set_password( auth: Auth, Json(input): Json, ) -> Result { - let has_password = state - .user_repo - .has_password_by_did(&auth.did) - .await - .ok() - .flatten() - .unwrap_or(false); - let has_passkeys = state - .user_repo - .has_passkeys(&auth.did) - .await - .unwrap_or(false); - let has_totp = state - .user_repo - .has_totp_enabled(&auth.did) - .await - .unwrap_or(false); - - let has_any_reauth_method = has_password || has_passkeys || has_totp; - - if has_any_reauth_method - && crate::api::server::reauth::check_reauth_required_cached( - &*state.session_repo, - &state.cache, - &auth.did, - ) - .await - { - return Ok(crate::api::server::reauth::reauth_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; let new_password = &input.new_password; if new_password.is_empty() { @@ -436,14 +334,13 @@ pub async fn set_password( return Err(ApiError::InvalidRequest(e.to_string())); } + let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did); + let user = state .user_repo - .get_password_info_by_did(&auth.did) + .get_password_info_by_did(did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting password info")? .ok_or(ApiError::AccountNotFound)?; if user.password_hash.is_some() { @@ -468,11 +365,8 @@ pub async fn set_password( .user_repo .set_new_user_password(user.id, &new_hash) .await - .map_err(|e| { - error!("DB error setting password: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("setting password")?; - info!(did = %&auth.did, "Password set for passkey-only account"); + info!(did = %did, "Password set for passkey-only account"); Ok(SuccessResponse::ok().into_response()) } diff --git a/crates/tranquil-pds/src/api/server/reauth.rs b/crates/tranquil-pds/src/api/server/reauth.rs index c06f10e..817b2a0 100644 --- a/crates/tranquil-pds/src/api/server/reauth.rs +++ b/crates/tranquil-pds/src/api/server/reauth.rs @@ -1,4 +1,4 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use axum::{ Json, extract::State, @@ -11,10 +11,11 @@ use tracing::{error, info, warn}; use tranquil_db_traits::{SessionRepository, UserRepository}; use crate::auth::{Active, Auth}; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; +use crate::state::AppState; use crate::types::PlainPassword; -const REAUTH_WINDOW_SECONDS: i64 = 300; +pub const REAUTH_WINDOW_SECONDS: i64 = 300; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -32,10 +33,7 @@ pub async fn get_reauth_status( .session_repo .get_last_reauth_at(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("getting last reauth")?; let reauth_required = is_reauth_required(last_reauth_at); let available_methods = @@ -70,10 +68,7 @@ pub async fn reauth_password( .user_repo .get_password_hash_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching password hash")? .ok_or(ApiError::AccountNotFound)?; let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); @@ -97,10 +92,7 @@ pub async fn reauth_password( let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did) .await - .map_err(|e| { - error!("DB error updating reauth: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via password"); Ok(Json(ReauthResponse { reauthed_at }).into_response()) @@ -117,15 +109,12 @@ pub async fn reauth_totp( auth: Auth, Json(input): Json, ) -> Result { - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &auth.did) - .await - { - warn!(did = %&auth.did, "TOTP verification rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(Some( - "Too many verification attempts. Please try again in a few minutes.".into(), - ))); - } + let _rate_limit = check_user_rate_limit_with_message::( + &state, + &auth.did, + "Too many verification attempts. Please try again in a few minutes.", + ) + .await?; let valid = crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.did, &input.code) @@ -140,10 +129,7 @@ pub async fn reauth_totp( let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did) .await - .map_err(|e| { - error!("DB error updating reauth: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via TOTP"); Ok(Json(ReauthResponse { reauthed_at }).into_response()) @@ -159,16 +145,11 @@ pub async fn reauth_passkey_start( State(state): State, auth: Auth, ) -> Result { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let stored_passkeys = state .user_repo .get_passkeys_for_user(&auth.did) .await - .map_err(|e| { - error!("Failed to get passkeys: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("getting passkeys")?; if stored_passkeys.is_empty() { return Err(ApiError::NoPasskeys); @@ -185,10 +166,7 @@ pub async fn reauth_passkey_start( ))); } - let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| { - error!("Failed to create WebAuthn config: {:?}", e); - ApiError::InternalError(None) - })?; + let webauthn = &state.webauthn_config; let (rcr, auth_state) = webauthn.start_authentication(passkeys).map_err(|e| { error!("Failed to start passkey authentication: {:?}", e); @@ -204,10 +182,7 @@ pub async fn reauth_passkey_start( .user_repo .save_webauthn_challenge(&auth.did, "authentication", &state_json) .await - .map_err(|e| { - error!("Failed to save authentication state: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("saving authentication state")?; let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); Ok(Json(PasskeyReauthStartResponse { options }).into_response()) @@ -224,16 +199,11 @@ pub async fn reauth_passkey_finish( auth: Auth, Json(input): Json, ) -> Result { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let auth_state_json = state .user_repo .load_webauthn_challenge(&auth.did, "authentication") .await - .map_err(|e| { - error!("Failed to load authentication state: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("loading authentication state")? .ok_or(ApiError::NoChallengeInProgress)?; let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication = @@ -248,12 +218,8 @@ pub async fn reauth_passkey_finish( ApiError::InvalidCredential })?; - let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| { - error!("Failed to create WebAuthn config: {:?}", e); - ApiError::InternalError(None) - })?; - - let auth_result = webauthn + let auth_result = state + .webauthn_config .finish_authentication(&credential, &auth_state) .map_err(|e| { warn!(did = %&auth.did, "Passkey re-auth failed: {:?}", e); @@ -287,10 +253,7 @@ pub async fn reauth_passkey_finish( let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did) .await - .map_err(|e| { - error!("DB error updating reauth: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via passkey"); Ok(Json(ReauthResponse { reauthed_at }).into_response()) @@ -418,7 +381,7 @@ pub async fn check_legacy_session_mfa( ) -> bool { match session_repo.get_session_mfa_status(did).await { Ok(Some(status)) => { - if !status.legacy_login { + if status.login_type.is_modern() { return true; } if status.mfa_verified { diff --git a/crates/tranquil-pds/src/api/server/service_auth.rs b/crates/tranquil-pds/src/api/server/service_auth.rs index 1b78a04..5f2ac9d 100644 --- a/crates/tranquil-pds/src/api/server/service_auth.rs +++ b/crates/tranquil-pds/src/api/server/service_auth.rs @@ -51,8 +51,8 @@ pub async fn get_service_auth( headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { - let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let auth_header = crate::util::get_header_str(&headers, "Authorization"); + let dpop_proof = crate::util::get_header_str(&headers, "DPoP"); info!( has_auth_header = auth_header.is_some(), has_dpop_proof = dpop_proof.is_some(), @@ -94,7 +94,7 @@ pub async fn get_service_auth( .await { Ok(result) => crate::auth::AuthenticatedUser { - did: Did::new_unchecked(result.did), + did: unsafe { Did::new_unchecked(result.did) }, is_admin: false, status: AccountStatus::Active, scope: result.scope, diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index 414aed9..495e861 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -1,8 +1,13 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::api::{EmptyResponse, SuccessResponse}; -use crate::auth::{Active, Auth, Permissive}; -use crate::state::{AppState, RateLimitKind}; +use crate::auth::{ + Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa, + require_reauth_window, +}; +use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit}; +use crate::state::AppState; use crate::types::{AccountState, Did, Handle, PlainPassword}; +use crate::util::{pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::State, @@ -13,34 +18,9 @@ use bcrypt::verify; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; +use tranquil_db_traits::{SessionId, TokenFamilyId}; use tranquil_types::TokenId; -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "unknown".to_string() -} - -fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { - let identifier = identifier.trim(); - if identifier.contains('@') || identifier.starts_with("did:") { - identifier.to_string() - } else if !identifier.contains('.') { - format!("{}.{}", identifier.to_lowercase(), pds_hostname) - } else { - identifier.to_lowercase() - } -} - fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { stored_handle.to_string() } @@ -75,31 +55,25 @@ pub struct CreateSessionOutput { pub async fn create_session( State(state): State, - headers: HeaderMap, + rate_limit: RateLimited, Json(input): Json, ) -> Response { + let client_ip = rate_limit.client_ip(); info!( "create_session called with identifier: {}", input.identifier ); - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::Login, &client_ip) - .await - { - warn!(ip = %client_ip, "Login rate limit exceeded"); - return ApiError::RateLimitExceeded(None).into_response(); - } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let normalized_identifier = normalize_handle(&input.identifier, hostname_for_handles); + let pds_host = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); + let normalized_identifier = + NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles); info!( "Normalized identifier: {} -> {}", input.identifier, normalized_identifier ); let row = match state .user_repo - .get_login_full_by_identifier(&normalized_identifier) + .get_login_full_by_identifier(normalized_identifier.as_str()) .await { Ok(Some(row)) => row, @@ -165,8 +139,7 @@ pub async fn create_session( warn!("Login attempt for takendown account: {}", row.did); return ApiError::AccountTakedown.into_response(); } - let is_verified = - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; + let is_verified = row.channel_verification.has_any_verified(); let is_delegated = state .delegation_repo .is_delegated_account(&row.did) @@ -226,7 +199,7 @@ pub async fn create_session( refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - legacy_login: is_legacy_login, + login_type: tranquil_db_traits::LoginType::from(is_legacy_login), mfa_verified: false, scope: app_password_scopes.clone(), controller_did: app_password_controller.clone(), @@ -246,13 +219,13 @@ pub async fn create_session( ip = %client_ip, "Legacy login on TOTP-enabled account - sending notification" ); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login( state.user_repo.as_ref(), state.infra_repo.as_ref(), row.id, - &hostname, - &client_ip, + hostname, + client_ip, row.preferred_comms_channel, ) .await @@ -260,7 +233,7 @@ pub async fn create_session( error!("Failed to queue legacy login notification: {:?}", e); } } - let handle = full_handle(&row.handle, &pds_hostname); + let handle = full_handle(&row.handle, pds_host); let is_active = account_state.is_active(); let status = account_state.status_for_session().map(String::from); Json(CreateSessionOutput { @@ -270,7 +243,7 @@ pub async fn create_session( did: row.did, did_doc, email: row.email, - email_confirmed: Some(row.email_verified), + email_confirmed: Some(row.channel_verification.email), active: Some(is_active), status, }) @@ -292,16 +265,17 @@ pub async fn get_session( ); match db_result { Ok(Some(row)) => { - let (preferred_channel, preferred_channel_verified) = match row.preferred_comms_channel - { - tranquil_db_traits::CommsChannel::Email => ("email", row.email_verified), - tranquil_db_traits::CommsChannel::Discord => ("discord", row.discord_verified), - tranquil_db_traits::CommsChannel::Telegram => ("telegram", row.telegram_verified), - tranquil_db_traits::CommsChannel::Signal => ("signal", row.signal_verified), + let preferred_channel = match row.preferred_comms_channel { + tranquil_db_traits::CommsChannel::Email => "email", + tranquil_db_traits::CommsChannel::Discord => "discord", + tranquil_db_traits::CommsChannel::Telegram => "telegram", + tranquil_db_traits::CommsChannel::Signal => "signal", }; - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let handle = full_handle(&row.handle, &pds_hostname); + let preferred_channel_verified = row + .channel_verification + .is_verified(row.preferred_comms_channel); + let pds_hostname = pds_hostname(); + let handle = full_handle(&row.handle, pds_hostname); let account_state = AccountState::from_db_fields( row.deactivated_at, row.takedown_ref.clone(), @@ -313,7 +287,7 @@ pub async fn get_session( } else { None }; - let email_confirmed_value = can_read_email && row.email_verified; + let email_confirmed_value = can_read_email && row.channel_verification.email; let mut response = json!({ "handle": handle, "did": &auth.did, @@ -352,9 +326,10 @@ pub async fn delete_session( headers: axum::http::HeaderMap, _auth: Auth, ) -> Result { - let extracted = crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) + let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str( + &headers, + "Authorization", + )) .ok_or(ApiError::AuthenticationRequired)?; let jti = crate::auth::get_jti_from_token(&extracted.token) .map_err(|_| ApiError::AuthenticationFailed(None))?; @@ -374,19 +349,13 @@ pub async fn delete_session( pub async fn refresh_session( State(state): State, + _rate_limit: RateLimited, headers: axum::http::HeaderMap, ) -> Response { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::RefreshSession, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded"); - return ApiError::RateLimitExceeded(None).into_response(); - } - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( + &headers, + "Authorization", + )) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; @@ -503,15 +472,17 @@ pub async fn refresh_session( ); match db_result { Ok(Some(u)) => { - let (preferred_channel, preferred_channel_verified) = match u.preferred_comms_channel { - tranquil_db_traits::CommsChannel::Email => ("email", u.email_verified), - tranquil_db_traits::CommsChannel::Discord => ("discord", u.discord_verified), - tranquil_db_traits::CommsChannel::Telegram => ("telegram", u.telegram_verified), - tranquil_db_traits::CommsChannel::Signal => ("signal", u.signal_verified), + let preferred_channel = match u.preferred_comms_channel { + tranquil_db_traits::CommsChannel::Email => "email", + tranquil_db_traits::CommsChannel::Discord => "discord", + tranquil_db_traits::CommsChannel::Telegram => "telegram", + tranquil_db_traits::CommsChannel::Signal => "signal", }; - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let handle = full_handle(&u.handle, &pds_hostname); + let preferred_channel_verified = u + .channel_verification + .is_verified(u.preferred_comms_channel); + let pds_hostname = pds_hostname(); + let handle = full_handle(&u.handle, pds_hostname); let account_state = AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None); let mut response = json!({ @@ -520,7 +491,7 @@ pub async fn refresh_session( "handle": handle, "did": session_row.did, "email": u.email, - "emailConfirmed": u.email_verified, + "emailConfirmed": u.channel_verification.email, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "preferredLocale": u.preferred_locale, @@ -664,7 +635,7 @@ pub async fn confirm_signup( refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - legacy_login: false, + login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, scope: None, controller_did: None, @@ -675,12 +646,12 @@ pub async fn confirm_signup( return ApiError::InternalError(None).into_response(); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_welcome( state.user_repo.as_ref(), state.infra_repo.as_ref(), row.id, - &hostname, + hostname, ) .await { @@ -731,8 +702,7 @@ pub async fn resend_verification( return ApiError::InternalError(None).into_response(); } }; - let is_verified = - row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; + let is_verified = row.channel_verification.has_any_verified(); if is_verified { return ApiError::InvalidRequest("Account is already verified".into()).into_response(); } @@ -756,14 +726,14 @@ pub async fn resend_verification( let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( state.infra_repo.as_ref(), row.id, channel_str, &recipient, &formatted_token, - &hostname, + hostname, ) .await { @@ -804,19 +774,13 @@ pub async fn list_sessions( .session_repo .list_sessions_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error fetching JWT sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching JWT sessions")?; let oauth_rows = state .oauth_repo .list_sessions_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error fetching OAuth sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("fetching OAuth sessions")?; let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo { id: format!("jwt:{}", row.id), @@ -869,43 +833,36 @@ pub async fn revoke_session( Json(input): Json, ) -> Result { if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { - let session_id: i32 = jwt_id - .parse() + let session_id = jwt_id + .parse::() + .map(SessionId::new) .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; let access_jti = state .session_repo .get_session_access_jti_by_id(session_id, &auth.did) .await - .map_err(|e| { - error!("DB error in revoke_session: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("in revoke_session")? .ok_or(ApiError::SessionNotFound)?; state .session_repo .delete_session_by_id(session_id) .await - .map_err(|e| { - error!("DB error deleting session: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting session")?; let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti); if let Err(e) = state.cache.delete(&cache_key).await { warn!("Failed to invalidate session cache: {:?}", e); } info!(did = %&auth.did, session_id = %session_id, "JWT session revoked"); } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { - let session_id: i32 = oauth_id - .parse() + let session_id = oauth_id + .parse::() + .map(TokenFamilyId::new) .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; let deleted = state .oauth_repo .delete_session_by_id(session_id, &auth.did) .await - .map_err(|e| { - error!("DB error deleting OAuth session: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting OAuth session")?; if deleted == 0 { return Err(ApiError::SessionNotFound); } @@ -932,36 +889,24 @@ pub async fn revoke_all_sessions( .session_repo .delete_sessions_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error revoking JWT sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking JWT sessions")?; let jti_typed = TokenId::from(jti.clone()); state .oauth_repo .delete_sessions_by_did_except(&auth.did, &jti_typed) .await - .map_err(|e| { - error!("DB error revoking OAuth sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking OAuth sessions")?; } else { state .session_repo .delete_sessions_by_did_except_jti(&auth.did, &jti) .await - .map_err(|e| { - error!("DB error revoking JWT sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking JWT sessions")?; state .oauth_repo .delete_sessions_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error revoking OAuth sessions: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking OAuth sessions")?; } info!(did = %&auth.did, "All other sessions revoked"); @@ -983,10 +928,7 @@ pub async fn get_legacy_login_preference( .user_repo .get_legacy_login_pref(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("getting legacy login pref")? .ok_or(ApiError::AccountNotFound)?; Ok(Json(LegacyLoginPreferenceOutput { allow_legacy_login: pref.allow_legacy_login, @@ -1006,38 +948,26 @@ pub async fn update_legacy_login_preference( auth: Auth, Json(input): Json, ) -> Result { - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await - { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; - if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await { - return Ok(crate::api::server::reauth::reauth_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } + let reauth_mfa = match require_reauth_window(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), + }; let updated = state .user_repo - .update_legacy_login(&auth.did, input.allow_legacy_login) + .update_legacy_login(reauth_mfa.did(), input.allow_legacy_login) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating legacy login")?; if !updated { return Err(ApiError::AccountNotFound); } info!( - did = %&auth.did, + did = %session_mfa.did(), allow_legacy_login = input.allow_legacy_login, "Legacy login preference updated" ); @@ -1071,10 +1001,7 @@ pub async fn update_locale( .user_repo .update_locale(&auth.did, &input.preferred_locale) .await - .map_err(|e| { - error!("DB error updating locale: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating locale")?; if !updated { return Err(ApiError::AccountNotFound); } diff --git a/crates/tranquil-pds/src/api/server/totp.rs b/crates/tranquil-pds/src/api/server/totp.rs index 0f5cd44..d05d4cf 100644 --- a/crates/tranquil-pds/src/api/server/totp.rs +++ b/crates/tranquil-pds/src/api/server/totp.rs @@ -1,13 +1,15 @@ use crate::api::EmptyResponse; -use crate::api::error::ApiError; -use crate::auth::{Active, Auth}; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{ - decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, - generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, - verify_backup_code, verify_totp_code, + Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, + generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code, + is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa, + verify_totp_code, verify_totp_mfa, }; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; +use crate::state::AppState; use crate::types::PlainPassword; +use crate::util::pds_hostname; use axum::{ Json, extract::State, @@ -30,9 +32,11 @@ pub async fn create_totp_secret( State(state): State, auth: Auth, ) -> Result { - match state.user_repo.get_totp_record(&auth.did).await { - Ok(Some(record)) if record.verified => return Err(ApiError::TotpAlreadyEnabled), - Ok(_) => {} + use tranquil_db_traits::TotpRecordState; + + match state.user_repo.get_totp_record_state(&auth.did).await { + Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled), + Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => {} Err(e) => { error!("DB error checking TOTP: {:?}", e); return Err(ApiError::InternalError(None)); @@ -45,16 +49,13 @@ pub async fn create_totp_secret( .user_repo .get_handle_by_did(&auth.did) .await - .map_err(|e| { - error!("DB error fetching handle: {:?}", e); - ApiError::InternalError(None) - })? + .log_db_err("fetching handle")? .ok_or(ApiError::AccountNotFound)?; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let uri = generate_totp_uri(&secret, &handle, &hostname); + let hostname = pds_hostname(); + let uri = generate_totp_uri(&secret, &handle, hostname); - let qr_code = generate_qr_png_base64(&secret, &handle, &hostname).map_err(|e| { + let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| { error!("Failed to generate QR code: {:?}", e); ApiError::InternalError(Some("Failed to generate QR code".into())) })?; @@ -68,10 +69,7 @@ pub async fn create_totp_secret( .user_repo .upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION) .await - .map_err(|e| { - error!("Failed to store TOTP secret: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("storing TOTP secret")?; let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret); @@ -101,16 +99,18 @@ pub async fn enable_totp( auth: Auth, Json(input): Json, ) -> Result { - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &auth.did) - .await - { - warn!(did = %&auth.did, "TOTP verification rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } + use tranquil_db_traits::TotpRecordState; - let totp_record = match state.user_repo.get_totp_record(&auth.did).await { - Ok(Some(row)) => row, + let _rate_limit = check_user_rate_limit_with_message::( + &state, + &auth.did, + "Too many verification attempts. Please try again in a few minutes.", + ) + .await?; + + let unverified_record = match state.user_repo.get_totp_record_state(&auth.did).await { + Ok(Some(TotpRecordState::Unverified(record))) => record, + Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled), Ok(None) => return Err(ApiError::TotpNotEnabled), Err(e) => { error!("DB error fetching TOTP: {:?}", e); @@ -118,13 +118,9 @@ pub async fn enable_totp( } }; - if totp_record.verified { - return Err(ApiError::TotpAlreadyEnabled); - } - let secret = decrypt_totp_secret( - &totp_record.secret_encrypted, - totp_record.encryption_version, + &unverified_record.secret_encrypted, + unverified_record.encryption_version, ) .map_err(|e| { error!("Failed to decrypt TOTP secret: {:?}", e); @@ -152,10 +148,7 @@ pub async fn enable_totp( .user_repo .enable_totp_with_backup_codes(&auth.did, &backup_hashes) .await - .map_err(|e| { - error!("Failed to enable TOTP: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("enabling TOTP")?; info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len()); @@ -173,79 +166,28 @@ pub async fn disable_totp( auth: Auth, Json(input): Json, ) -> Result { - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await - { - return Ok(crate::api::server::reauth::legacy_mfa_required_response( - &*state.user_repo, - &*state.session_repo, - &auth.did, - ) - .await); - } - - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &auth.did) - .await - { - warn!(did = %&auth.did, "TOTP verification rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - - let password_hash = state - .user_repo - .get_password_hash_by_did(&auth.did) - .await - .map_err(|e| { - error!("DB error fetching user: {:?}", e); - ApiError::InternalError(None) - })? - .ok_or(ApiError::AccountNotFound)?; - - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); - if !password_valid { - return Err(ApiError::InvalidPassword("Password is incorrect".into())); - } - - let totp_record = match state.user_repo.get_totp_record(&auth.did).await { - Ok(Some(row)) if row.verified => row, - Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled), - Err(e) => { - error!("DB error fetching TOTP: {:?}", e); - return Err(ApiError::InternalError(None)); - } + let session_mfa = match require_legacy_session_mfa(&state, &auth).await { + Ok(proof) => proof, + Err(response) => return Ok(response), }; - let code = input.code.trim(); - let code_valid = if is_backup_code_format(code) { - verify_backup_code_for_user(&state, &auth.did, code).await - } else { - let secret = decrypt_totp_secret( - &totp_record.secret_encrypted, - totp_record.encryption_version, - ) - .map_err(|e| { - error!("Failed to decrypt TOTP secret: {:?}", e); - ApiError::InternalError(None) - })?; - verify_totp_code(&secret, code) - }; + let _rate_limit = check_user_rate_limit_with_message::( + &state, + session_mfa.did(), + "Too many verification attempts. Please try again in a few minutes.", + ) + .await?; - if !code_valid { - return Err(ApiError::InvalidCode(Some( - "Invalid verification code".into(), - ))); - } + let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?; + let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?; state .user_repo - .delete_totp_and_backup_codes(&auth.did) + .delete_totp_and_backup_codes(totp_mfa.did()) .await - .map_err(|e| { - error!("Failed to delete TOTP: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("deleting TOTP")?; - info!(did = %&auth.did, "TOTP disabled"); + info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); Ok(EmptyResponse::ok().into_response()) } @@ -262,9 +204,11 @@ pub async fn get_totp_status( State(state): State, auth: Auth, ) -> Result { - let enabled = match state.user_repo.get_totp_record(&auth.did).await { - Ok(Some(row)) => row.verified, - Ok(None) => false, + use tranquil_db_traits::TotpRecordState; + + let enabled = match state.user_repo.get_totp_record_state(&auth.did).await { + Ok(Some(TotpRecordState::Verified(_))) => true, + Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => false, Err(e) => { error!("DB error fetching TOTP status: {:?}", e); return Err(ApiError::InternalError(None)); @@ -275,10 +219,7 @@ pub async fn get_totp_status( .user_repo .count_unused_backup_codes(&auth.did) .await - .map_err(|e| { - error!("DB error counting backup codes: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("counting backup codes")?; Ok(Json(GetTotpStatusResponse { enabled, @@ -305,53 +246,15 @@ pub async fn regenerate_backup_codes( auth: Auth, Json(input): Json, ) -> Result { - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &auth.did) - .await - { - warn!(did = %&auth.did, "TOTP verification rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - - let password_hash = state - .user_repo - .get_password_hash_by_did(&auth.did) - .await - .map_err(|e| { - error!("DB error fetching user: {:?}", e); - ApiError::InternalError(None) - })? - .ok_or(ApiError::AccountNotFound)?; - - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); - if !password_valid { - return Err(ApiError::InvalidPassword("Password is incorrect".into())); - } - - let totp_record = match state.user_repo.get_totp_record(&auth.did).await { - Ok(Some(row)) if row.verified => row, - Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled), - Err(e) => { - error!("DB error fetching TOTP: {:?}", e); - return Err(ApiError::InternalError(None)); - } - }; - - let secret = decrypt_totp_secret( - &totp_record.secret_encrypted, - totp_record.encryption_version, + let _rate_limit = check_user_rate_limit_with_message::( + &state, + &auth.did, + "Too many verification attempts. Please try again in a few minutes.", ) - .map_err(|e| { - error!("Failed to decrypt TOTP secret: {:?}", e); - ApiError::InternalError(None) - })?; + .await?; - let code = input.code.trim(); - if !verify_totp_code(&secret, code) { - return Err(ApiError::InvalidCode(Some( - "Invalid verification code".into(), - ))); - } + let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?; + let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?; let backup_codes = generate_backup_codes(); let backup_hashes: Vec<_> = backup_codes @@ -365,14 +268,11 @@ pub async fn regenerate_backup_codes( state .user_repo - .replace_backup_codes(&auth.did, &backup_hashes) + .replace_backup_codes(totp_mfa.did(), &backup_hashes) .await - .map_err(|e| { - error!("Failed to regenerate backup codes: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("replacing backup codes")?; - info!(did = %&auth.did, "Backup codes regenerated"); + info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method()); Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response()) } @@ -410,20 +310,22 @@ pub async fn verify_totp_or_backup_for_user( did: &crate::types::Did, code: &str, ) -> bool { + use tranquil_db_traits::TotpRecordState; + let code = code.trim(); if is_backup_code_format(code) { return verify_backup_code_for_user(state, did, code).await; } - let totp_record = match state.user_repo.get_totp_record(did).await { - Ok(Some(row)) if row.verified => row, + let verified_record = match state.user_repo.get_totp_record_state(did).await { + Ok(Some(TotpRecordState::Verified(record))) => record, _ => return false, }; let secret = match decrypt_totp_secret( - &totp_record.secret_encrypted, - totp_record.encryption_version, + &verified_record.secret_encrypted, + verified_record.encryption_version, ) { Ok(s) => s, Err(_) => return false, diff --git a/crates/tranquil-pds/src/api/server/trusted_devices.rs b/crates/tranquil-pds/src/api/server/trusted_devices.rs index 0ea9835..cc1a806 100644 --- a/crates/tranquil-pds/src/api/server/trusted_devices.rs +++ b/crates/tranquil-pds/src/api/server/trusted_devices.rs @@ -1,5 +1,5 @@ use crate::api::SuccessResponse; -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use axum::{ Json, extract::State, @@ -79,10 +79,7 @@ pub async fn list_trusted_devices( .oauth_repo .list_trusted_devices(&auth.did) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("listing trusted devices")?; let devices = rows .into_iter() @@ -134,10 +131,7 @@ pub async fn revoke_trusted_device( .oauth_repo .revoke_device_trust(&device_id) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("revoking device trust")?; info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked"); Ok(SuccessResponse::ok().into_response()) @@ -175,10 +169,7 @@ pub async fn update_trusted_device( .oauth_repo .update_device_friendly_name(&device_id, input.friendly_name.as_deref()) .await - .map_err(|e| { - error!("DB error: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating device friendly name")?; info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated"); Ok(SuccessResponse::ok().into_response()) diff --git a/crates/tranquil-pds/src/api/server/verify_email.rs b/crates/tranquil-pds/src/api/server/verify_email.rs index f98296b..4ea5f4d 100644 --- a/crates/tranquil-pds/src/api/server/verify_email.rs +++ b/crates/tranquil-pds/src/api/server/verify_email.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::state::AppState; +use crate::util::pds_hostname; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -70,7 +71,7 @@ pub async fn resend_migration_verification( return Ok(Json(ResendMigrationVerificationOutput { sent: true })); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); let token = crate::auth::verification_token::generate_migration_token(&user.did, &email); let formatted_token = crate::auth::verification_token::format_token_for_display(&token); @@ -80,7 +81,7 @@ pub async fn resend_migration_verification( user.id, &email, &formatted_token, - &hostname, + hostname, ) .await { diff --git a/crates/tranquil-pds/src/api/server/verify_token.rs b/crates/tranquil-pds/src/api/server/verify_token.rs index 2dbc3c9..724b5fd 100644 --- a/crates/tranquil-pds/src/api/server/verify_token.rs +++ b/crates/tranquil-pds/src/api/server/verify_token.rs @@ -1,8 +1,8 @@ -use crate::api::error::ApiError; +use crate::api::error::{ApiError, DbResultExt}; use crate::types::Did; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use crate::auth::verification_token::{ VerificationPurpose, normalize_token_input, verify_token_signature, @@ -81,25 +81,19 @@ async fn handle_migration_verification( .user_repo .get_verification_info(&did_typed) .await - .map_err(|e| { - warn!(error = ?e, "Database error during migration verification"); - ApiError::InternalError(None) - })? + .log_db_err("during migration verification")? .ok_or(ApiError::AccountNotFound)?; if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) { return Err(ApiError::IdentifierMismatch); } - if !user.email_verified { + if !user.channel_verification.email { state .user_repo .set_email_verified_flag(user.id) .await - .map_err(|e| { - warn!(error = ?e, "Failed to update email_verified status"); - ApiError::InternalError(None) - })?; + .log_db_err("updating email_verified status")?; } info!(did = %did, "Migration email verified successfully"); @@ -125,7 +119,7 @@ async fn handle_channel_update( .user_repo .get_id_by_did(&did_typed) .await - .map_err(|_| ApiError::InternalError(None))? + .log_db_err("fetching user id")? .ok_or(ApiError::AccountNotFound)?; match channel { @@ -134,10 +128,7 @@ async fn handle_channel_update( .user_repo .verify_email_channel(user_id, identifier) .await - .map_err(|e| { - error!("Failed to update email channel: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating email channel")?; if !success { return Err(ApiError::EmailTaken); } @@ -147,30 +138,21 @@ async fn handle_channel_update( .user_repo .verify_discord_channel(user_id, identifier) .await - .map_err(|e| { - error!("Failed to update discord channel: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating discord channel")?; } "telegram" => { state .user_repo .verify_telegram_channel(user_id, identifier) .await - .map_err(|e| { - error!("Failed to update telegram channel: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating telegram channel")?; } "signal" => { state .user_repo .verify_signal_channel(user_id, identifier) .await - .map_err(|e| { - error!("Failed to update signal channel: {:?}", e); - ApiError::InternalError(None) - })?; + .log_db_err("updating signal channel")?; } _ => { return Err(ApiError::InvalidChannel); @@ -200,16 +182,10 @@ async fn handle_signup_verification( .user_repo .get_verification_info(&did_typed) .await - .map_err(|e| { - warn!(error = ?e, "Database error during signup verification"); - ApiError::InternalError(None) - })? + .log_db_err("during signup verification")? .ok_or(ApiError::AccountNotFound)?; - let is_verified = user.email_verified - || user.discord_verified - || user.telegram_verified - || user.signal_verified; + let is_verified = user.channel_verification.has_any_verified(); if is_verified { info!(did = %did, "Account already verified"); return Ok(Json(VerifyTokenOutput { @@ -226,40 +202,28 @@ async fn handle_signup_verification( .user_repo .set_email_verified_flag(user.id) .await - .map_err(|e| { - warn!(error = ?e, "Failed to update email verified status"); - ApiError::InternalError(None) - })?; + .log_db_err("updating email verified status")?; } "discord" => { state .user_repo .set_discord_verified_flag(user.id) .await - .map_err(|e| { - warn!(error = ?e, "Failed to update discord verified status"); - ApiError::InternalError(None) - })?; + .log_db_err("updating discord verified status")?; } "telegram" => { state .user_repo .set_telegram_verified_flag(user.id) .await - .map_err(|e| { - warn!(error = ?e, "Failed to update telegram verified status"); - ApiError::InternalError(None) - })?; + .log_db_err("updating telegram verified status")?; } "signal" => { state .user_repo .set_signal_verified_flag(user.id) .await - .map_err(|e| { - warn!(error = ?e, "Failed to update signal verified status"); - ApiError::InternalError(None) - })?; + .log_db_err("updating signal verified status")?; } _ => { return Err(ApiError::InvalidChannel); diff --git a/crates/tranquil-pds/src/auth/account_verified.rs b/crates/tranquil-pds/src/auth/account_verified.rs new file mode 100644 index 0000000..0d0dd08 --- /dev/null +++ b/crates/tranquil-pds/src/auth/account_verified.rs @@ -0,0 +1,61 @@ +use axum::response::{IntoResponse, Response}; + +use super::AuthenticatedUser; +use crate::api::error::ApiError; +use crate::state::AppState; +use crate::types::Did; + +pub struct AccountVerified<'a> { + user: &'a AuthenticatedUser, +} + +impl<'a> AccountVerified<'a> { + pub fn did(&self) -> &Did { + &self.user.did + } + + pub fn user(&self) -> &AuthenticatedUser { + self.user + } +} + +pub async fn require_verified_or_delegated<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result, Response> { + let is_verified = state + .user_repo + .has_verified_comms_channel(&user.did) + .await + .unwrap_or(false); + + if is_verified { + return Ok(AccountVerified { user }); + } + + let is_delegated = state + .delegation_repo + .is_delegated_account(&user.did) + .await + .unwrap_or(false); + + if is_delegated { + return Ok(AccountVerified { user }); + } + + Err(ApiError::AccountNotVerified.into_response()) +} + +pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> { + match state.user_repo.is_account_migrated(did).await { + Ok(true) => Err(ApiError::AccountMigrated.into_response()), + Ok(false) => Ok(()), + Err(e) => { + tracing::error!("Failed to check migration status: {:?}", e); + Err( + ApiError::InternalError(Some("Failed to verify migration status".into())) + .into_response(), + ) + } + } +} diff --git a/crates/tranquil-pds/src/auth/auth_extractor.rs b/crates/tranquil-pds/src/auth/auth_extractor.rs deleted file mode 100644 index 7f849bb..0000000 --- a/crates/tranquil-pds/src/auth/auth_extractor.rs +++ /dev/null @@ -1,547 +0,0 @@ -mod common; -mod helpers; - -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::Utc; -use common::{base_url, client, create_account_and_login, pds_endpoint}; -use helpers::verify_new_account; -use reqwest::StatusCode; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -fn generate_pkce() -> (String, String) { - let verifier_bytes: [u8; 32] = rand::random(); - let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); - let mut hasher = Sha256::new(); - hasher.update(code_verifier.as_bytes()); - let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); - (code_verifier, code_challenge) -} - -async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { - let mock_server = MockServer::start().await; - let metadata = json!({ - "client_id": mock_server.uri(), - "client_name": "Auth Extractor Test Client", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "dpop_bound_access_tokens": dpop_bound - }); - Mock::given(method("GET")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) - .mount(&mock_server) - .await; - mock_server -} - -async fn get_oauth_session( - http_client: &reqwest::Client, - url: &str, - dpop_bound: bool, -) -> (String, String, String, String) { - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; - let handle = format!("ae{}", suffix); - let password = "AuthExtract123!"; - let create_res = http_client - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ - "handle": handle, - "email": format!("{}@example.com", handle), - "password": password - })) - .send() - .await - .unwrap(); - assert_eq!(create_res.status(), StatusCode::OK); - let account: Value = create_res.json().await.unwrap(); - let did = account["did"].as_str().unwrap().to_string(); - verify_new_account(http_client, &did).await; - - let redirect_uri = "https://example.com/auth-callback"; - let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; - let client_id = mock_client.uri(); - let (code_verifier, code_challenge) = generate_pkce(); - - let par_body: Value = http_client - .post(format!("{}/oauth/par", url)) - .form(&[ - ("response_type", "code"), - ("client_id", &client_id), - ("redirect_uri", redirect_uri), - ("code_challenge", &code_challenge), - ("code_challenge_method", "S256"), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let request_uri = par_body["request_uri"].as_str().unwrap(); - - let auth_res = http_client - .post(format!("{}/oauth/authorize", url)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&json!({ - "request_uri": request_uri, - "username": &handle, - "password": password, - "remember_device": false - })) - .send() - .await - .unwrap(); - let auth_body: Value = auth_res.json().await.unwrap(); - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); - - if location.contains("/oauth/consent") { - let consent_res = http_client - .post(format!("{}/oauth/authorize/consent", url)) - .header("Content-Type", "application/json") - .json(&json!({ - "request_uri": request_uri, - "approved_scopes": ["atproto"], - "remember": false - })) - .send() - .await - .unwrap(); - let consent_body: Value = consent_res.json().await.unwrap(); - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); - } - - let code = location - .split("code=") - .nth(1) - .unwrap() - .split('&') - .next() - .unwrap(); - - let token_body: Value = http_client - .post(format!("{}/oauth/token", url)) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier), - ("client_id", &client_id), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - ( - token_body["access_token"].as_str().unwrap().to_string(), - token_body["refresh_token"].as_str().unwrap().to_string(), - client_id, - did, - ) -} - -#[tokio::test] -async fn test_oauth_token_works_with_bearer_auth() { - let url = base_url().await; - let http_client = client(); - let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; - - let res = http_client - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&access_token) - .send() - .await - .unwrap(); - - assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with RequiredAuth extractor"); - let body: Value = res.json().await.unwrap(); - assert_eq!(body["did"].as_str().unwrap(), did); -} - -#[tokio::test] -async fn test_session_token_still_works() { - let url = base_url().await; - let http_client = client(); - let (jwt, did) = create_account_and_login(&http_client).await; - - let res = http_client - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&jwt) - .send() - .await - .unwrap(); - - assert_eq!(res.status(), StatusCode::OK, "Session token should still work"); - let body: Value = res.json().await.unwrap(); - assert_eq!(body["did"].as_str().unwrap(), did); -} - - -#[tokio::test] -async fn test_oauth_admin_extractor_allows_oauth_tokens() { - let url = base_url().await; - let http_client = client(); - - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; - let handle = format!("adm{}", suffix); - let password = "AdminOAuth123!"; - let create_res = http_client - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ - "handle": handle, - "email": format!("{}@example.com", handle), - "password": password - })) - .send() - .await - .unwrap(); - assert_eq!(create_res.status(), StatusCode::OK); - let account: Value = create_res.json().await.unwrap(); - let did = account["did"].as_str().unwrap().to_string(); - verify_new_account(&http_client, &did).await; - - let pool = common::get_test_db_pool().await; - sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) - .execute(pool) - .await - .expect("Failed to mark user as admin"); - - let redirect_uri = "https://example.com/admin-callback"; - let mock_client = setup_mock_client_metadata(redirect_uri, false).await; - let client_id = mock_client.uri(); - let (code_verifier, code_challenge) = generate_pkce(); - - let par_body: Value = http_client - .post(format!("{}/oauth/par", url)) - .form(&[ - ("response_type", "code"), - ("client_id", &client_id), - ("redirect_uri", redirect_uri), - ("code_challenge", &code_challenge), - ("code_challenge_method", "S256"), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let request_uri = par_body["request_uri"].as_str().unwrap(); - - let auth_res = http_client - .post(format!("{}/oauth/authorize", url)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&json!({ - "request_uri": request_uri, - "username": &handle, - "password": password, - "remember_device": false - })) - .send() - .await - .unwrap(); - let auth_body: Value = auth_res.json().await.unwrap(); - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); - if location.contains("/oauth/consent") { - let consent_res = http_client - .post(format!("{}/oauth/authorize/consent", url)) - .header("Content-Type", "application/json") - .json(&json!({ - "request_uri": request_uri, - "approved_scopes": ["atproto"], - "remember": false - })) - .send() - .await - .unwrap(); - let consent_body: Value = consent_res.json().await.unwrap(); - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); - } - - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); - let token_body: Value = http_client - .post(format!("{}/oauth/token", url)) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier), - ("client_id", &client_id), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let access_token = token_body["access_token"].as_str().unwrap(); - - let res = http_client - .get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did)) - .bearer_auth(access_token) - .send() - .await - .unwrap(); - - assert_eq!( - res.status(), - StatusCode::OK, - "OAuth token for admin user should work with admin endpoint" - ); -} - -#[tokio::test] -async fn test_expired_oauth_token_returns_proper_error() { - let url = base_url().await; - let http_client = client(); - - let now = Utc::now().timestamp(); - let header = json!({"alg": "HS256", "typ": "at+jwt"}); - let payload = json!({ - "iss": url, - "sub": "did:plc:test123", - "aud": url, - "iat": now - 7200, - "exp": now - 3600, - "jti": "expired-token", - "sid": "expired-session", - "scope": "atproto", - "client_id": "https://example.com" - }); - let fake_token = format!( - "{}.{}.{}", - URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), - URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), - URL_SAFE_NO_PAD.encode([1u8; 32]) - ); - - let res = http_client - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&fake_token) - .send() - .await - .unwrap(); - - assert_eq!( - res.status(), - StatusCode::UNAUTHORIZED, - "Expired token should be rejected" - ); -} - -#[tokio::test] -async fn test_dpop_nonce_error_has_proper_headers() { - let url = base_url().await; - let pds_url = pds_endpoint(); - let http_client = client(); - - let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; - let handle = format!("dpop{}", suffix); - let create_res = http_client - .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ - "handle": handle, - "email": format!("{}@test.com", handle), - "password": "DpopTest123!" - })) - .send() - .await - .unwrap(); - assert_eq!(create_res.status(), StatusCode::OK); - let account: Value = create_res.json().await.unwrap(); - let did = account["did"].as_str().unwrap(); - verify_new_account(&http_client, did).await; - - let redirect_uri = "https://example.com/dpop-callback"; - let mock_server = MockServer::start().await; - let client_id = mock_server.uri(); - let metadata = json!({ - "client_id": &client_id, - "client_name": "DPoP Test Client", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "dpop_bound_access_tokens": true - }); - Mock::given(method("GET")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) - .mount(&mock_server) - .await; - - let (code_verifier, code_challenge) = generate_pkce(); - let par_body: Value = http_client - .post(format!("{}/oauth/par", url)) - .form(&[ - ("response_type", "code"), - ("client_id", &client_id), - ("redirect_uri", redirect_uri), - ("code_challenge", &code_challenge), - ("code_challenge_method", "S256"), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - let request_uri = par_body["request_uri"].as_str().unwrap(); - let auth_res = http_client - .post(format!("{}/oauth/authorize", url)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&json!({ - "request_uri": request_uri, - "username": &handle, - "password": "DpopTest123!", - "remember_device": false - })) - .send() - .await - .unwrap(); - let auth_body: Value = auth_res.json().await.unwrap(); - let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); - if location.contains("/oauth/consent") { - let consent_res = http_client - .post(format!("{}/oauth/authorize/consent", url)) - .header("Content-Type", "application/json") - .json(&json!({ - "request_uri": request_uri, - "approved_scopes": ["atproto"], - "remember": false - })) - .send() - .await - .unwrap(); - let consent_body: Value = consent_res.json().await.unwrap(); - location = consent_body["redirect_uri"].as_str().unwrap().to_string(); - } - - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); - - let token_endpoint = format!("{}/oauth/token", pds_url); - let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); - - let token_res = http_client - .post(format!("{}/oauth/token", url)) - .header("DPoP", &dpop_proof) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier), - ("client_id", &client_id), - ]) - .send() - .await - .unwrap(); - - let token_status = token_res.status(); - let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string()); - let token_body: Value = token_res.json().await.unwrap(); - - let access_token = if token_status == StatusCode::OK { - token_body["access_token"].as_str().unwrap().to_string() - } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { - let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); - let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); - - let retry_res = http_client - .post(format!("{}/oauth/token", url)) - .header("DPoP", &dpop_proof_with_nonce) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier), - ("client_id", &client_id), - ]) - .send() - .await - .unwrap(); - let retry_body: Value = retry_res.json().await.unwrap(); - retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string() - } else { - panic!("Token exchange failed unexpectedly: {:?}", token_body); - }; - - let res = http_client - .get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .header("Authorization", format!("DPoP {}", access_token)) - .send() - .await - .unwrap(); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail"); - - let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap()); - assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); - assert!( - www_auth.unwrap().contains("use_dpop_nonce"), - "WWW-Authenticate should indicate dpop nonce required" - ); - - let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); - assert!(nonce.is_some(), "Should return DPoP-Nonce header"); - - let body: Value = res.json().await.unwrap(); - assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); -} - -fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { - use p256::ecdsa::{SigningKey, signature::Signer}; - use p256::elliptic_curve::rand_core::OsRng; - - let signing_key = SigningKey::random(&mut OsRng); - let verifying_key = signing_key.verifying_key(); - let point = verifying_key.to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); - let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); - - let jwk = json!({ - "kty": "EC", - "crv": "P-256", - "x": x, - "y": y - }); - - let header = { - let h = json!({ - "typ": "dpop+jwt", - "alg": "ES256", - "jwk": jwk.clone() - }); - h - }; - - let mut payload = json!({ - "jti": uuid::Uuid::new_v4().to_string(), - "htm": method, - "htu": uri, - "iat": Utc::now().timestamp() - }); - if let Some(n) = nonce { - payload["nonce"] = json!(n); - } - - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); - let signing_input = format!("{}.{}", header_b64, payload_b64); - - let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); - let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); - - let proof = format!("{}.{}", signing_input, sig_b64); - (jwk, proof) -} diff --git a/crates/tranquil-pds/src/auth/extractor.rs b/crates/tranquil-pds/src/auth/extractor.rs index 98e8f8b..9016dd5 100644 --- a/crates/tranquil-pds/src/auth/extractor.rs +++ b/crates/tranquil-pds/src/auth/extractor.rs @@ -9,7 +9,7 @@ use tracing::{debug, error, info}; use super::{ AccountStatus, AuthSource, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier, - is_service_token, validate_bearer_token_for_service_auth, + is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth, }; use crate::api::error::ApiError; use crate::oauth::scopes::{RepoAction, ScopePermissions}; @@ -293,7 +293,7 @@ async fn extract_auth_internal( return Ok(ExtractedAuth::Service(claims)); } - let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let dpop_proof = crate::util::get_header_str(&parts.headers, "DPoP"); let method = parts.method.as_str(); let uri = build_full_url(&parts.uri.to_string()); @@ -358,6 +358,22 @@ impl std::ops::Deref for Auth

{ } } +impl AsRef for Auth

{ + fn as_ref(&self) -> &AuthenticatedUser { + &self.0 + } +} + +impl VerifyScope for Auth

{ + fn needs_scope_check(&self) -> bool { + self.0.is_oauth() + } + + fn permissions(&self) -> ScopePermissions { + self.0.permissions() + } +} + impl FromRequestParts for Auth

{ type Rejection = AuthError; @@ -418,10 +434,7 @@ impl FromRequestParts for ServiceAuth { ) -> Result { match extract_auth_internal(parts, state).await? { ExtractedAuth::Service(claims) => { - let did: Did = claims - .iss - .parse() - .map_err(|_| AuthError::AuthenticationFailed)?; + let did = claims.iss.clone(); Ok(ServiceAuth { did, claims }) } ExtractedAuth::User(_) => Err(AuthError::AuthenticationFailed), @@ -438,10 +451,7 @@ impl OptionalFromRequestParts for ServiceAuth { ) -> Result, Self::Rejection> { match extract_auth_internal(parts, state).await { Ok(ExtractedAuth::Service(claims)) => { - let did: Did = claims - .iss - .parse() - .map_err(|_| AuthError::AuthenticationFailed)?; + let did = claims.iss.clone(); Ok(Some(ServiceAuth { did, claims })) } Ok(ExtractedAuth::User(_)) => Err(AuthError::AuthenticationFailed), @@ -503,10 +513,7 @@ impl FromRequestParts for AuthAny

{ Ok(AuthAny::User(Auth(user, PhantomData))) } ExtractedAuth::Service(claims) => { - let did: Did = claims - .iss - .parse() - .map_err(|_| AuthError::AuthenticationFailed)?; + let did = claims.iss.clone(); Ok(AuthAny::Service(ServiceAuth { did, claims })) } } @@ -526,10 +533,7 @@ impl OptionalFromRequestParts for AuthAny

{ Ok(Some(AuthAny::User(Auth(user, PhantomData)))) } Ok(ExtractedAuth::Service(claims)) => { - let did: Did = claims - .iss - .parse() - .map_err(|_| AuthError::AuthenticationFailed)?; + let did = claims.iss.clone(); Ok(Some(AuthAny::Service(ServiceAuth { did, claims }))) } Err(AuthError::MissingToken) => Ok(None), diff --git a/crates/tranquil-pds/src/auth/login_identifier.rs b/crates/tranquil-pds/src/auth/login_identifier.rs new file mode 100644 index 0000000..15acbdf --- /dev/null +++ b/crates/tranquil-pds/src/auth/login_identifier.rs @@ -0,0 +1,135 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NormalizedLoginIdentifier(String); + +impl NormalizedLoginIdentifier { + pub fn normalize(identifier: &str, pds_hostname: &str) -> Self { + let trimmed = identifier.trim(); + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); + + let normalized = match () { + _ if stripped.starts_with("did:") => stripped.to_string(), + _ if stripped.contains('@') => stripped.to_string(), + _ if !stripped.contains('.') => { + format!("{}.{}", stripped.to_lowercase(), pds_hostname) + } + _ => stripped.to_lowercase(), + }; + + Self(normalized) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for NormalizedLoginIdentifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for NormalizedLoginIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BareLoginIdentifier(String); + +impl BareLoginIdentifier { + pub fn from_identifier(identifier: &str, pds_hostname: &str) -> Self { + let trimmed = identifier.trim(); + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); + let suffix = format!(".{}", pds_hostname); + let bare = stripped.strip_suffix(&suffix).unwrap_or(stripped); + Self(bare.to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for BareLoginIdentifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for BareLoginIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalized_identifier_handles_did() { + let id = NormalizedLoginIdentifier::normalize("did:plc:abc123", "example.com"); + assert_eq!(id.as_str(), "did:plc:abc123"); + } + + #[test] + fn normalized_identifier_handles_email() { + let id = NormalizedLoginIdentifier::normalize("user@example.org", "pds.example.com"); + assert_eq!(id.as_str(), "user@example.org"); + } + + #[test] + fn normalized_identifier_handles_bare_handle() { + let id = NormalizedLoginIdentifier::normalize("alice", "pds.example.com"); + assert_eq!(id.as_str(), "alice.pds.example.com"); + } + + #[test] + fn normalized_identifier_handles_bare_handle_with_at_prefix() { + let id = NormalizedLoginIdentifier::normalize("@alice", "pds.example.com"); + assert_eq!(id.as_str(), "alice.pds.example.com"); + } + + #[test] + fn normalized_identifier_handles_full_handle() { + let id = NormalizedLoginIdentifier::normalize("alice.bsky.social", "pds.example.com"); + assert_eq!(id.as_str(), "alice.bsky.social"); + } + + #[test] + fn normalized_identifier_handles_uppercase() { + let id = NormalizedLoginIdentifier::normalize("ALICE", "pds.example.com"); + assert_eq!(id.as_str(), "alice.pds.example.com"); + + let id2 = NormalizedLoginIdentifier::normalize("ALICE.BSKY.SOCIAL", "pds.example.com"); + assert_eq!(id2.as_str(), "alice.bsky.social"); + } + + #[test] + fn normalized_identifier_trims_whitespace() { + let id = NormalizedLoginIdentifier::normalize(" alice ", "pds.example.com"); + assert_eq!(id.as_str(), "alice.pds.example.com"); + } + + #[test] + fn bare_identifier_strips_hostname_suffix() { + let id = BareLoginIdentifier::from_identifier("alice.pds.example.com", "pds.example.com"); + assert_eq!(id.as_str(), "alice"); + } + + #[test] + fn bare_identifier_preserves_non_matching() { + let id = BareLoginIdentifier::from_identifier("alice.bsky.social", "pds.example.com"); + assert_eq!(id.as_str(), "alice.bsky.social"); + } + + #[test] + fn bare_identifier_strips_at_prefix() { + let id = BareLoginIdentifier::from_identifier("@alice.pds.example.com", "pds.example.com"); + assert_eq!(id.as_str(), "alice"); + } +} diff --git a/crates/tranquil-pds/src/auth/mfa_verified.rs b/crates/tranquil-pds/src/auth/mfa_verified.rs new file mode 100644 index 0000000..ec861c9 --- /dev/null +++ b/crates/tranquil-pds/src/auth/mfa_verified.rs @@ -0,0 +1,234 @@ +use axum::response::Response; + +use super::AuthenticatedUser; +use crate::state::AppState; +use crate::types::Did; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MfaMethod { + Totp, + Passkey, + Password, + RecoveryCode, + SessionReauth, +} + +impl MfaMethod { + pub fn as_str(&self) -> &'static str { + match self { + Self::Totp => "totp", + Self::Passkey => "passkey", + Self::Password => "password", + Self::RecoveryCode => "recovery_code", + Self::SessionReauth => "session_reauth", + } + } +} + +impl std::fmt::Display for MfaMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +pub struct MfaVerified<'a> { + user: &'a AuthenticatedUser, + method: MfaMethod, +} + +impl<'a> MfaVerified<'a> { + fn new(user: &'a AuthenticatedUser, method: MfaMethod) -> Self { + Self { user, method } + } + + pub(crate) fn from_totp(user: &'a AuthenticatedUser) -> Self { + Self::new(user, MfaMethod::Totp) + } + + pub(crate) fn from_password(user: &'a AuthenticatedUser) -> Self { + Self::new(user, MfaMethod::Password) + } + + pub(crate) fn from_recovery_code(user: &'a AuthenticatedUser) -> Self { + Self::new(user, MfaMethod::RecoveryCode) + } + + pub(crate) fn from_session_reauth(user: &'a AuthenticatedUser) -> Self { + Self::new(user, MfaMethod::SessionReauth) + } + + pub fn user(&self) -> &AuthenticatedUser { + self.user + } + + pub fn did(&self) -> &Did { + &self.user.did + } + + pub fn method(&self) -> MfaMethod { + self.method + } +} + +pub async fn require_legacy_session_mfa<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result, Response> { + use crate::api::server::reauth::{check_legacy_session_mfa, legacy_mfa_required_response}; + + if check_legacy_session_mfa(&*state.session_repo, &user.did).await { + Ok(MfaVerified::from_session_reauth(user)) + } else { + Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + } +} + +pub async fn require_reauth_window<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result, Response> { + use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; + use chrono::Utc; + + let status = state + .session_repo + .get_session_mfa_status(&user.did) + .await + .ok() + .flatten(); + + match status { + Some(s) => { + if let Some(last_reauth) = s.last_reauth_at { + let elapsed = Utc::now().signed_duration_since(last_reauth); + if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { + return Ok(MfaVerified::from_session_reauth(user)); + } + } + Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + } + None => { + Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + } + } +} + +pub async fn require_reauth_window_if_available<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result>, Response> { + use crate::api::server::reauth::{check_reauth_required_cached, reauth_required_response}; + + let has_password = state + .user_repo + .has_password_by_did(&user.did) + .await + .ok() + .flatten() + .unwrap_or(false); + let has_passkeys = state + .user_repo + .has_passkeys(&user.did) + .await + .unwrap_or(false); + let has_totp = state + .user_repo + .has_totp_enabled(&user.did) + .await + .unwrap_or(false); + + let has_any_reauth_method = has_password || has_passkeys || has_totp; + + if !has_any_reauth_method { + return Ok(None); + } + + if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await { + Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + } else { + Ok(Some(MfaVerified::from_session_reauth(user))) + } +} + +pub async fn verify_password_mfa<'a>( + state: &AppState, + user: &'a AuthenticatedUser, + password: &str, +) -> Result, crate::api::error::ApiError> { + let hash = state + .user_repo + .get_password_hash_by_did(&user.did) + .await + .ok() + .flatten(); + + match hash { + Some(h) => { + if bcrypt::verify(password, &h).unwrap_or(false) { + Ok(MfaVerified::from_password(user)) + } else { + Err(crate::api::error::ApiError::InvalidPassword( + "Password is incorrect".into(), + )) + } + } + None => Err(crate::api::error::ApiError::AccountNotFound), + } +} + +pub async fn verify_totp_mfa<'a>( + state: &AppState, + user: &'a AuthenticatedUser, + code: &str, +) -> Result, crate::api::error::ApiError> { + use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code}; + use tranquil_db_traits::TotpRecordState; + + let code = code.trim(); + + if is_backup_code_format(code) { + let backup_codes = state + .user_repo + .get_unused_backup_codes(&user.did) + .await + .ok() + .unwrap_or_default(); + let code_upper = code.to_uppercase(); + + let matched = backup_codes + .iter() + .find(|row| crate::auth::verify_backup_code(&code_upper, &row.code_hash)); + + return match matched { + Some(row) => { + let _ = state.user_repo.mark_backup_code_used(row.id).await; + Ok(MfaVerified::from_recovery_code(user)) + } + None => Err(crate::api::error::ApiError::InvalidCode(Some( + "Invalid backup code".into(), + ))), + }; + } + + let verified_record = match state.user_repo.get_totp_record_state(&user.did).await { + Ok(Some(TotpRecordState::Verified(record))) => record, + _ => { + return Err(crate::api::error::ApiError::TotpNotEnabled); + } + }; + + let secret = decrypt_totp_secret( + &verified_record.secret_encrypted, + verified_record.encryption_version, + ) + .map_err(|_| crate::api::error::ApiError::InternalError(None))?; + + if verify_totp_code(&secret, code) { + let _ = state.user_repo.update_totp_last_used(&user.did).await; + Ok(MfaVerified::from_totp(user)) + } else { + Err(crate::api::error::ApiError::InvalidCode(Some( + "Invalid verification code".into(), + ))) + } +} diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index ef693f3..05e91e6 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -10,16 +10,33 @@ use crate::types::Did; use tranquil_db::UserRepository; use tranquil_db_traits::OAuthRepository; +pub mod account_verified; pub mod extractor; +pub mod login_identifier; +pub mod mfa_verified; pub mod scope_check; +pub mod scope_verified; pub mod service; pub mod verification_token; pub mod webauthn; +pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier}; + +pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated}; pub use extractor::{ Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, }; +pub use mfa_verified::{ + MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window, + require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa, +}; +pub use scope_verified::{ + AccountManage, AccountRead, BatchWriteScopes, BlobScopeAction, BlobUpload, ControllerDid, + IdentityAccess, PrincipalDid, RepoCreate, RepoDelete, RepoScopeAction, RepoUpdate, RepoUpsert, + RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind, + verify_batch_write_scopes, +}; pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; pub use tranquil_auth::{ @@ -409,7 +426,7 @@ async fn validate_bearer_token_with_options_internal( .claims .act .as_ref() - .map(|a| Did::new_unchecked(a.sub.clone())); + .map(|a| unsafe { Did::new_unchecked(a.sub.clone()) }); let status = AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at); return Ok(AuthenticatedUser { @@ -461,12 +478,14 @@ async fn validate_bearer_token_with_options_internal( None }; return Ok(AuthenticatedUser { - did: Did::new_unchecked(oauth_token.did), + did: unsafe { Did::new_unchecked(oauth_token.did) }, key_bytes, is_admin: oauth_token.is_admin, status, scope: oauth_info.scope, - controller_did: oauth_info.controller_did.map(Did::new_unchecked), + controller_did: oauth_info + .controller_did + .map(|d| unsafe { Did::new_unchecked(d) }), auth_source: AuthSource::OAuth, }); } else { @@ -545,7 +564,7 @@ pub async fn validate_token_with_dpop( None }; Ok(AuthenticatedUser { - did: Did::new_unchecked(result.did), + did: unsafe { Did::new_unchecked(result.did) }, key_bytes, is_admin: user_info.is_admin, status, diff --git a/crates/tranquil-pds/src/auth/scope_verified.rs b/crates/tranquil-pds/src/auth/scope_verified.rs new file mode 100644 index 0000000..436710c --- /dev/null +++ b/crates/tranquil-pds/src/auth/scope_verified.rs @@ -0,0 +1,502 @@ +use std::marker::PhantomData; +use std::ops::Deref; + +use axum::response::{IntoResponse, Response}; + +use crate::api::error::ApiError; +use crate::oauth::scopes::{ + AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, +}; +use crate::types::Did; + +use super::AuthenticatedUser; + +#[derive(Debug, Clone)] +pub struct PrincipalDid(Did); + +impl PrincipalDid { + pub fn as_did(&self) -> &Did { + &self.0 + } + + pub fn into_did(self) -> Did { + self.0 + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl Deref for PrincipalDid { + type Target = Did; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for PrincipalDid { + fn as_ref(&self) -> &Did { + &self.0 + } +} + +impl std::fmt::Display for PrincipalDid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone)] +pub struct ControllerDid(Did); + +impl ControllerDid { + pub fn as_did(&self) -> &Did { + &self.0 + } + + pub fn into_did(self) -> Did { + self.0 + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl Deref for ControllerDid { + type Target = Did; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for ControllerDid { + fn as_ref(&self) -> &Did { + &self.0 + } +} + +impl std::fmt::Display for ControllerDid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug)] +pub struct ScopeVerificationError { + message: String, +} + +impl ScopeVerificationError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ScopeVerificationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ScopeVerificationError {} + +impl IntoResponse for ScopeVerificationError { + fn into_response(self) -> Response { + ApiError::InsufficientScope(Some(self.message)).into_response() + } +} + +mod private { + pub trait Sealed {} + pub trait RepoScopeSealed {} + pub trait BlobScopeSealed {} +} + +pub trait ScopeAction: private::Sealed {} + +pub trait RepoScopeAction: ScopeAction + private::RepoScopeSealed {} + +pub trait BlobScopeAction: ScopeAction + private::BlobScopeSealed {} + +pub struct RepoCreate; +pub struct RepoUpdate; +pub struct RepoDelete; +pub struct RepoUpsert; +pub struct BlobUpload; +pub struct RpcCall; +pub struct AccountRead; +pub struct AccountManage; +pub struct IdentityAccess; + +impl private::Sealed for RepoCreate {} +impl private::Sealed for RepoUpdate {} +impl private::Sealed for RepoDelete {} +impl private::Sealed for RepoUpsert {} +impl private::Sealed for BlobUpload {} +impl private::Sealed for RpcCall {} +impl private::Sealed for AccountRead {} +impl private::Sealed for AccountManage {} +impl private::Sealed for IdentityAccess {} + +impl private::RepoScopeSealed for RepoCreate {} +impl private::RepoScopeSealed for RepoUpdate {} +impl private::RepoScopeSealed for RepoDelete {} +impl private::RepoScopeSealed for RepoUpsert {} + +impl private::BlobScopeSealed for BlobUpload {} + +impl ScopeAction for RepoCreate {} +impl ScopeAction for RepoUpdate {} +impl ScopeAction for RepoDelete {} +impl ScopeAction for RepoUpsert {} +impl ScopeAction for BlobUpload {} +impl ScopeAction for RpcCall {} +impl ScopeAction for AccountRead {} +impl ScopeAction for AccountManage {} +impl ScopeAction for IdentityAccess {} + +impl RepoScopeAction for RepoCreate {} +impl RepoScopeAction for RepoUpdate {} +impl RepoScopeAction for RepoDelete {} +impl RepoScopeAction for RepoUpsert {} + +impl BlobScopeAction for BlobUpload {} + +pub struct ScopeVerified<'a, A: ScopeAction> { + user: &'a AuthenticatedUser, + _action: PhantomData, +} + +impl<'a, A: ScopeAction> ScopeVerified<'a, A> { + pub fn user(&self) -> &AuthenticatedUser { + self.user + } + + pub fn principal_did(&self) -> PrincipalDid { + PrincipalDid(self.user.did.clone()) + } + + pub fn controller_did(&self) -> Option { + self.user.controller_did.clone().map(ControllerDid) + } + + pub fn is_admin(&self) -> bool { + self.user.is_admin + } +} + +pub struct BatchWriteScopes<'a> { + user: &'a AuthenticatedUser, + has_creates: bool, + has_updates: bool, + has_deletes: bool, +} + +impl<'a> BatchWriteScopes<'a> { + pub fn principal_did(&self) -> PrincipalDid { + PrincipalDid(self.user.did.clone()) + } + + pub fn controller_did(&self) -> Option { + self.user.controller_did.clone().map(ControllerDid) + } + + pub fn user(&self) -> &AuthenticatedUser { + self.user + } + + pub fn has_creates(&self) -> bool { + self.has_creates + } + + pub fn has_updates(&self) -> bool { + self.has_updates + } + + pub fn has_deletes(&self) -> bool { + self.has_deletes + } +} + +pub fn verify_batch_write_scopes<'a, T, C, F>( + auth: &'a impl VerifyScope, + user: &'a AuthenticatedUser, + writes: &[T], + get_collection: F, + classify: C, +) -> Result, ScopeVerificationError> +where + F: Fn(&T) -> &str, + C: Fn(&T) -> WriteOpKind, +{ + use std::collections::HashSet; + + let create_collections: HashSet<&str> = writes + .iter() + .filter(|w| matches!(classify(w), WriteOpKind::Create)) + .map(&get_collection) + .collect(); + + let update_collections: HashSet<&str> = writes + .iter() + .filter(|w| matches!(classify(w), WriteOpKind::Update)) + .map(&get_collection) + .collect(); + + let delete_collections: HashSet<&str> = writes + .iter() + .filter(|w| matches!(classify(w), WriteOpKind::Delete)) + .map(&get_collection) + .collect(); + + if auth.needs_scope_check() { + create_collections.iter().try_for_each(|c| { + auth.permissions() + .assert_repo(RepoAction::Create, c) + .map_err(|e| ScopeVerificationError::new(e.to_string())) + })?; + + update_collections.iter().try_for_each(|c| { + auth.permissions() + .assert_repo(RepoAction::Update, c) + .map_err(|e| ScopeVerificationError::new(e.to_string())) + })?; + + delete_collections.iter().try_for_each(|c| { + auth.permissions() + .assert_repo(RepoAction::Delete, c) + .map_err(|e| ScopeVerificationError::new(e.to_string())) + })?; + } + + Ok(BatchWriteScopes { + user, + has_creates: !create_collections.is_empty(), + has_updates: !update_collections.is_empty(), + has_deletes: !delete_collections.is_empty(), + }) +} + +#[derive(Clone, Copy)] +pub enum WriteOpKind { + Create, + Update, + Delete, +} + +pub trait VerifyScope { + fn needs_scope_check(&self) -> bool; + fn permissions(&self) -> ScopePermissions; + + fn verify_repo_create<'a>( + &'a self, + collection: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_repo(RepoAction::Create, collection) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_repo_update<'a>( + &'a self, + collection: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_repo(RepoAction::Update, collection) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_repo_delete<'a>( + &'a self, + collection: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_repo(RepoAction::Delete, collection) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_repo_upsert<'a>( + &'a self, + collection: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_repo(RepoAction::Create, collection) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + self.permissions() + .assert_repo(RepoAction::Update, collection) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_blob_upload<'a>( + &'a self, + mime_type: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_blob(mime_type) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_rpc<'a>( + &'a self, + aud: &str, + lxm: &str, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_rpc(aud, lxm) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_account_read<'a>( + &'a self, + attr: AccountAttr, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_account(attr, AccountAction::Read) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_account_manage<'a>( + &'a self, + attr: AccountAttr, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_account(attr, AccountAction::Manage) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } + + fn verify_identity<'a>( + &'a self, + attr: IdentityAttr, + ) -> Result, ScopeVerificationError> + where + Self: AsRef, + { + if !self.needs_scope_check() { + return Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }); + } + self.permissions() + .assert_identity(attr) + .map_err(|e| ScopeVerificationError::new(e.to_string()))?; + Ok(ScopeVerified { + user: self.as_ref(), + _action: PhantomData, + }) + } +} diff --git a/crates/tranquil-pds/src/auth/service.rs b/crates/tranquil-pds/src/auth/service.rs index 5b885c2..ce33eed 100644 --- a/crates/tranquil-pds/src/auth/service.rs +++ b/crates/tranquil-pds/src/auth/service.rs @@ -1,3 +1,5 @@ +use crate::types::Did; +use crate::util::pds_hostname; use anyhow::{Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -42,10 +44,10 @@ pub struct DidService { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceTokenClaims { - pub iss: String, + pub iss: Did, #[serde(default)] - pub sub: Option, - pub aud: String, + pub sub: Option, + pub aud: Did, pub exp: usize, #[serde(default)] pub iat: Option, @@ -56,8 +58,8 @@ pub struct ServiceTokenClaims { } impl ServiceTokenClaims { - pub fn subject(&self) -> &str { - self.sub.as_deref().unwrap_or(&self.iss) + pub fn subject(&self) -> &Did { + self.sub.as_ref().unwrap_or(&self.iss) } } @@ -78,8 +80,7 @@ impl ServiceTokenVerifier { let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") .unwrap_or_else(|_| "https://plc.directory".to_string()); - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let pds_did = format!("did:web:{}", pds_hostname); let client = Client::builder() @@ -130,7 +131,7 @@ impl ServiceTokenVerifier { return Err(anyhow!("Token expired")); } - if claims.aud != self.pds_did { + if claims.aud.as_str() != self.pds_did { return Err(anyhow!( "Invalid audience: expected {}, got {}", self.pds_did, @@ -154,7 +155,7 @@ impl ServiceTokenVerifier { } } - let did = &claims.iss; + let did = claims.iss.as_str(); let public_key = self.resolve_signing_key(did).await?; let signature_bytes = URL_SAFE_NO_PAD diff --git a/crates/tranquil-pds/src/auth/webauthn.rs b/crates/tranquil-pds/src/auth/webauthn.rs index 5e884da..9ca123c 100644 --- a/crates/tranquil-pds/src/auth/webauthn.rs +++ b/crates/tranquil-pds/src/auth/webauthn.rs @@ -7,7 +7,7 @@ pub struct WebAuthnConfig { impl WebAuthnConfig { pub fn new(hostname: &str) -> Result { - let rp_id = hostname.to_string(); + let rp_id = hostname.split(':').next().unwrap_or(hostname).to_string(); let rp_origin = Url::parse(&format!("https://{}", hostname)) .map_err(|e| format!("Invalid origin URL: {}", e))?; diff --git a/crates/tranquil-pds/src/cid_types.rs b/crates/tranquil-pds/src/cid_types.rs new file mode 100644 index 0000000..58630f6 --- /dev/null +++ b/crates/tranquil-pds/src/cid_types.rs @@ -0,0 +1,101 @@ +use cid::Cid; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CommitCid(Cid); + +impl CommitCid { + pub fn new(cid: Cid) -> Self { + Self(cid) + } + + pub fn as_cid(&self) -> &Cid { + &self.0 + } + + pub fn into_cid(self) -> Cid { + self.0 + } +} + +impl From for CommitCid { + fn from(cid: Cid) -> Self { + Self(cid) + } +} + +impl From for Cid { + fn from(commit_cid: CommitCid) -> Self { + commit_cid.0 + } +} + +impl FromStr for CommitCid { + type Err = cid::Error; + + fn from_str(s: &str) -> Result { + Cid::from_str(s).map(Self) + } +} + +impl fmt::Display for CommitCid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for CommitCid { + fn as_ref(&self) -> &Cid { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RecordCid(Cid); + +impl RecordCid { + pub fn new(cid: Cid) -> Self { + Self(cid) + } + + pub fn as_cid(&self) -> &Cid { + &self.0 + } + + pub fn into_cid(self) -> Cid { + self.0 + } +} + +impl From for RecordCid { + fn from(cid: Cid) -> Self { + Self(cid) + } +} + +impl From for Cid { + fn from(record_cid: RecordCid) -> Self { + record_cid.0 + } +} + +impl FromStr for RecordCid { + type Err = cid::Error; + + fn from_str(s: &str) -> Result { + Cid::from_str(s).map(Self) + } +} + +impl fmt::Display for RecordCid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for RecordCid { + fn as_ref(&self) -> &Cid { + &self.0 + } +} diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index f203aa0..0dd0792 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use std::time::Duration; use chrono::Utc; -use tokio::sync::watch; use tokio::time::interval; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tranquil_comms::{ CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message, @@ -96,7 +96,7 @@ impl CommsService { !self.senders.is_empty() } - pub async fn run(self, mut shutdown: watch::Receiver) { + pub async fn run(self, shutdown: CancellationToken) { if self.senders.is_empty() { warn!( "Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured." @@ -116,11 +116,9 @@ impl CommsService { error!(error = %e, "Failed to process comms batch"); } } - _ = shutdown.changed() => { - if *shutdown.borrow() { - info!("Comms service shutting down"); - break; - } + _ = shutdown.cancelled() => { + info!("Comms service shutting down"); + break; } } } @@ -278,7 +276,7 @@ pub mod repo { &[("hostname", hostname), ("handle", &prefs.handle)], ); let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), @@ -309,7 +307,7 @@ pub mod repo { &[("handle", &prefs.handle), ("code", code)], ); let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), @@ -422,7 +420,7 @@ pub mod repo { &[("handle", &prefs.handle), ("code", code)], ); let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), @@ -453,7 +451,7 @@ pub mod repo { &[("handle", &prefs.handle), ("token", token)], ); let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), @@ -484,7 +482,7 @@ pub mod repo { &[("handle", &prefs.handle), ("url", recovery_url)], ); let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), @@ -614,7 +612,7 @@ pub mod repo { &[("handle", &prefs.handle), ("code", code)], ); let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); - let channel = channel_from_str(&prefs.preferred_channel); + let channel = prefs.preferred_channel; infra_repo .enqueue_comms( Some(user_id), diff --git a/crates/tranquil-pds/src/crawlers.rs b/crates/tranquil-pds/src/crawlers.rs index 499835f..bb9ee94 100644 --- a/crates/tranquil-pds/src/crawlers.rs +++ b/crates/tranquil-pds/src/crawlers.rs @@ -1,11 +1,14 @@ use crate::circuit_breaker::CircuitBreaker; use crate::sync::firehose::SequencedEvent; +use crate::util::pds_hostname; use reqwest::Client; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use tokio::sync::{broadcast, watch}; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; +use tranquil_db_traits::RepoEventType; const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60; @@ -40,7 +43,10 @@ impl Crawlers { } pub fn from_env() -> Option { - let hostname = std::env::var("PDS_HOSTNAME").ok()?; + let hostname = pds_hostname(); + if hostname == "localhost" { + return None; + } let crawler_urls: Vec = std::env::var("CRAWLERS") .unwrap_or_default() @@ -53,7 +59,7 @@ impl Crawlers { return None; } - Some(Self::new(hostname, crawler_urls)) + Some(Self::new(hostname.to_string(), crawler_urls)) } fn should_notify(&self) -> bool { @@ -143,7 +149,7 @@ impl Crawlers { pub async fn start_crawlers_service( crawlers: Arc, mut firehose_rx: broadcast::Receiver, - mut shutdown: watch::Receiver, + shutdown: CancellationToken, ) { info!( hostname = %crawlers.hostname, @@ -157,7 +163,7 @@ pub async fn start_crawlers_service( result = firehose_rx.recv() => { match result { Ok(event) => { - if event.event_type == "commit" { + if event.event_type == RepoEventType::Commit { crawlers.notify_of_update().await; } } @@ -171,11 +177,9 @@ pub async fn start_crawlers_service( } } } - _ = shutdown.changed() => { - if *shutdown.borrow() { - info!("Crawlers service shutting down"); - break; - } + _ = shutdown.cancelled() => { + info!("Crawlers service shutting down"); + break; } } } diff --git a/crates/tranquil-pds/src/delegation/mod.rs b/crates/tranquil-pds/src/delegation/mod.rs index 0020d7f..1b3c520 100644 --- a/crates/tranquil-pds/src/delegation/mod.rs +++ b/crates/tranquil-pds/src/delegation/mod.rs @@ -1,4 +1,12 @@ +pub mod roles; pub mod scopes; -pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes}; +pub use roles::{ + CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers, + verify_can_be_controller, verify_can_control_accounts, +}; +pub use scopes::{ + InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, + intersect_scopes, validate_delegation_scopes, +}; pub use tranquil_db_traits::DelegationActionType; diff --git a/crates/tranquil-pds/src/delegation/roles.rs b/crates/tranquil-pds/src/delegation/roles.rs new file mode 100644 index 0000000..aa6ff1b --- /dev/null +++ b/crates/tranquil-pds/src/delegation/roles.rs @@ -0,0 +1,108 @@ +use axum::response::{IntoResponse, Response}; + +use crate::api::error::ApiError; +use crate::auth::AuthenticatedUser; +use crate::state::AppState; +use crate::types::Did; + +pub struct CanAddControllers<'a> { + user: &'a AuthenticatedUser, +} + +pub struct CanControlAccounts<'a> { + user: &'a AuthenticatedUser, +} + +pub struct CanBeController<'a> { + controller_did: &'a Did, +} + +impl<'a> CanAddControllers<'a> { + pub fn did(&self) -> &Did { + &self.user.did + } + + pub fn user(&self) -> &AuthenticatedUser { + self.user + } +} + +impl<'a> CanControlAccounts<'a> { + pub fn did(&self) -> &Did { + &self.user.did + } + + pub fn user(&self) -> &AuthenticatedUser { + self.user + } +} + +impl<'a> CanBeController<'a> { + pub fn did(&self) -> &Did { + self.controller_did + } +} + +pub async fn verify_can_add_controllers<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result, Response> { + match state.delegation_repo.controls_any_accounts(&user.did).await { + Ok(true) => Err(ApiError::InvalidDelegation( + "Cannot add controllers to an account that controls other accounts".into(), + ) + .into_response()), + Ok(false) => Ok(CanAddControllers { user }), + Err(e) => { + tracing::error!("Failed to check delegation status: {:?}", e); + Err( + ApiError::InternalError(Some("Failed to verify delegation status".into())) + .into_response(), + ) + } + } +} + +pub async fn verify_can_control_accounts<'a>( + state: &AppState, + user: &'a AuthenticatedUser, +) -> Result, Response> { + match state.delegation_repo.has_any_controllers(&user.did).await { + Ok(true) => Err(ApiError::InvalidDelegation( + "Cannot create delegated accounts from a controlled account".into(), + ) + .into_response()), + Ok(false) => Ok(CanControlAccounts { user }), + Err(e) => { + tracing::error!("Failed to check controller status: {:?}", e); + Err( + ApiError::InternalError(Some("Failed to verify controller status".into())) + .into_response(), + ) + } + } +} + +pub async fn verify_can_be_controller<'a>( + state: &AppState, + controller_did: &'a Did, +) -> Result, Response> { + match state + .delegation_repo + .has_any_controllers(controller_did) + .await + { + Ok(true) => Err(ApiError::InvalidDelegation( + "Cannot add a controlled account as a controller".into(), + ) + .into_response()), + Ok(false) => Ok(CanBeController { controller_did }), + Err(e) => { + tracing::error!("Failed to check controller status: {:?}", e); + Err( + ApiError::InternalError(Some("Failed to verify controller status".into())) + .into_response(), + ) + } + } +} diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index d8e3f6e..8029561 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -1,5 +1,9 @@ use std::collections::HashSet; +pub use tranquil_db_traits::{ + DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError, +}; + pub struct ScopePreset { pub name: &'static str, pub label: &'static str, @@ -107,35 +111,9 @@ fn split_scope(scope: &str) -> (&str, Option<&str>) { } } -pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> { - if scopes.is_empty() { - return Ok(()); - } - - scopes.split_whitespace().try_for_each(|scope| { - let (base, _) = split_scope(scope); - if is_valid_scope_prefix(base) { - Ok(()) - } else { - Err(format!("Invalid scope: {}", scope)) - } - }) -} - -fn is_valid_scope_prefix(base: &str) -> bool { - const VALID_PREFIXES: [&str; 7] = [ - "atproto", - "repo:", - "blob:", - "rpc:", - "account:", - "identity:", - "transition:", - ]; - - VALID_PREFIXES - .iter() - .any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix)) +pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> { + ValidatedDelegationScope::new(scopes)?; + Ok(()) } #[cfg(test)] diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 1453e2e..78ef4f3 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod appview; pub mod auth; pub mod cache; +pub mod cid_types; pub mod circuit_breaker; pub mod comms; pub mod config; @@ -35,9 +36,9 @@ use axum::{ use http::StatusCode; use serde_json::json; use state::AppState; -pub use sync::util::AccountStatus; use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; +pub use tranquil_db_traits::AccountStatus; pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; pub fn app(state: AppState) -> Router { diff --git a/crates/tranquil-pds/src/main.rs b/crates/tranquil-pds/src/main.rs index e4e6bab..20b28a2 100644 --- a/crates/tranquil-pds/src/main.rs +++ b/crates/tranquil-pds/src/main.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; use std::process::ExitCode; use std::sync::Arc; -use tokio::sync::watch; +use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; @@ -34,10 +34,20 @@ async fn main() -> ExitCode { } async fn run() -> Result<(), Box> { - let state = AppState::new().await?; - tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await; + let shutdown = CancellationToken::new(); - let (shutdown_tx, shutdown_rx) = watch::channel(false); + let shutdown_for_panic = shutdown.clone(); + let default_panic_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + error!("PANIC: {}", info); + shutdown_for_panic.cancel(); + default_panic_hook(info); + })); + + spawn_signal_handler(shutdown.clone()); + + let state = AppState::new(shutdown.clone()).await?; + tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await; let backfill_repo_repo = state.repo_repo.clone(); let backfill_block_store = state.block_store.clone(); @@ -77,7 +87,7 @@ async fn run() -> Result<(), Box> { comms_service = comms_service.register_sender(signal_sender); } - let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone())); + let comms_handle = tokio::spawn(comms_service.run(shutdown.clone())); let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { let crawlers = Arc::new( @@ -88,7 +98,7 @@ async fn run() -> Result<(), Box> { Some(tokio::spawn(start_crawlers_service( crawlers, firehose_rx, - shutdown_rx.clone(), + shutdown.clone(), ))) } else { warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)"); @@ -102,7 +112,7 @@ async fn run() -> Result<(), Box> { state.backup_repo.clone(), state.block_store.clone(), backup_storage, - shutdown_rx.clone(), + shutdown.clone(), ))) } else { warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)"); @@ -114,7 +124,7 @@ async fn run() -> Result<(), Box> { state.blob_repo.clone(), state.blob_store.clone(), state.sso_repo.clone(), - shutdown_rx, + shutdown.clone(), )); let app = tranquil_pds::app(state); @@ -136,7 +146,7 @@ async fn run() -> Result<(), Box> { .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; let server_result = axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal(shutdown_tx)) + .with_graceful_shutdown(shutdown.clone().cancelled_owned()) .await; comms_handle.await.ok(); @@ -158,37 +168,40 @@ async fn run() -> Result<(), Box> { Ok(()) } -async fn shutdown_signal(shutdown_tx: watch::Sender) { - let ctrl_c = async { - match tokio::signal::ctrl_c().await { - Ok(()) => {} - Err(e) => { - error!("Failed to install Ctrl+C handler: {}", e); +fn spawn_signal_handler(shutdown: CancellationToken) { + tokio::spawn(async move { + let ctrl_c = async { + match tokio::signal::ctrl_c().await { + Ok(()) => {} + Err(e) => { + error!("Failed to install Ctrl+C handler: {}", e); + std::future::pending::<()>().await; + } } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(e) => { + error!("Failed to install SIGTERM handler: {}", e); + std::future::pending::<()>().await; + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, } - }; - #[cfg(unix)] - let terminate = async { - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(mut signal) => { - signal.recv().await; - } - Err(e) => { - error!("Failed to install SIGTERM handler: {}", e); - std::future::pending::<()>().await; - } - } - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } - - info!("Shutdown signal received, stopping services..."); - shutdown_tx.send(true).ok(); + info!("Shutdown signal received, stopping services..."); + shutdown.cancel(); + }); } diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index 3b9463a..373514a 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -1,10 +1,16 @@ +use crate::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; use crate::oauth::{ - AuthFlowState, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, SessionId, + AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent, scopes::expand_include_scopes, }; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{ + OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit, + check_user_rate_limit, +}; +use crate::state::AppState; use crate::types::{Did, Handle, PlainPassword}; +use crate::util::{extract_client_ip, pds_hostname, pds_hostname_without_port}; use axum::{ Json, extract::{Query, State}, @@ -79,27 +85,6 @@ fn is_valid_scope(s: &str) -> bool { || s.starts_with("include:") } -fn validate_auth_flow_state( - flow_state: &AuthFlowState, - require_authenticated: bool, -) -> Option { - if flow_state.is_expired() { - return Some(json_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "Authorization request has expired", - )); - } - if require_authenticated && flow_state.is_pending() { - return Some(json_error( - StatusCode::FORBIDDEN, - "access_denied", - "Not authenticated", - )); - } - None -} - fn extract_device_cookie(headers: &HeaderMap) -> Option { headers .get("cookie") @@ -113,21 +98,6 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option { }) } -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "0.0.0.0".to_string() -} - fn extract_user_agent(headers: &HeaderMap) -> Option { headers .get("user-agent") @@ -282,21 +252,13 @@ pub async fn authorize_get( if let Some(ref login_hint) = request_data.parameters.login_hint { tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let normalized = if login_hint.contains('@') || login_hint.starts_with("did:") { - login_hint.clone() - } else if !login_hint.contains('.') { - format!("{}.{}", login_hint.to_lowercase(), hostname_for_handles) - } else { - login_hint.to_lowercase() - }; + let hostname_for_handles = pds_hostname_without_port(); + let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles); tracing::info!(normalized = %normalized, "Normalized login_hint"); match state .user_repo - .get_login_check_by_handle_or_email(&normalized) + .get_login_check_by_handle_or_email(normalized.as_str()) .await { Ok(Some(user)) => { @@ -340,7 +302,7 @@ pub async fn authorize_get( tracing::info!("No login_hint in request"); } - if request_data.parameters.prompt.as_deref() == Some("create") { + if request_data.parameters.prompt == Some(Prompt::Create) { return redirect_see_other(&format!( "/app/oauth/register?request_uri={}", url_encode(&request_uri) @@ -485,31 +447,11 @@ pub async fn authorize_accounts( pub async fn authorize_post( State(state): State, + _rate_limit: OAuthRateLimited, headers: HeaderMap, Json(form): Json, ) -> Response { let json_response = wants_json(&headers); - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth authorize rate limit exceeded"); - if json_response { - return ( - axum::http::StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "error": "RateLimitExceeded", - "error_description": "Too many login attempts. Please try again later." - })), - ) - .into_response(); - } - return redirect_to_frontend_error( - "RateLimitExceeded", - "Too many login attempts. Please try again later.", - ); - } let form_request_id = RequestId::from(form.request_uri.clone()); let request_data = match state .oauth_repo @@ -584,28 +526,18 @@ pub async fn authorize_post( url_encode(error_msg) )) }; - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let normalized_username = form.username.trim(); - let normalized_username = normalized_username - .strip_prefix('@') - .unwrap_or(normalized_username); - let normalized_username = if normalized_username.contains('@') { - normalized_username.to_string() - } else if !normalized_username.contains('.') { - format!("{}.{}", normalized_username, hostname_for_handles) - } else { - normalized_username.to_string() - }; + let hostname_for_handles = pds_hostname_without_port(); + let normalized_username = + NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles); tracing::debug!( original_username = %form.username, normalized_username = %normalized_username, - pds_hostname = %pds_hostname, + pds_hostname = %pds_hostname(), "Normalized username for lookup" ); let user = match state .user_repo - .get_login_info_by_handle_or_email(&normalized_username) + .get_login_info_by_handle_or_email(normalized_username.as_str()) .await { Ok(Some(u)) => u, @@ -624,10 +556,7 @@ pub async fn authorize_post( if user.takedown_ref.is_some() { return show_login_error("This account has been taken down.", json_response); } - let is_verified = user.email_verified - || user.discord_verified - || user.telegram_verified - || user.signal_verified; + let is_verified = user.channel_verification.has_any_verified(); if !is_verified { return show_login_error( "Please verify your account before logging in.", @@ -635,7 +564,7 @@ pub async fn authorize_post( ); } - if user.account_type == "delegated" { + if user.account_type.is_delegated() { if state .oauth_repo .set_authorization_did(&form_request_id, &user.did, None) @@ -748,14 +677,13 @@ pub async fn authorize_post( .await { Ok(challenge) => { - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), user.id, &challenge.code, - &hostname, + hostname, ) .await { @@ -792,9 +720,9 @@ pub async fn authorize_post( } else { let new_id = DeviceId::generate(); let device_data = DeviceData { - session_id: SessionId::generate().0, + session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), - ip_address: extract_client_ip(&headers), + ip_address: extract_client_ip(&headers, None), last_seen_at: Utc::now(), }; let new_device_id_typed = DeviceIdType::from(new_id.0.clone()); @@ -888,7 +816,7 @@ pub async fn authorize_post( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); if let Some(cookie) = new_cookie { ( @@ -905,7 +833,7 @@ pub async fn authorize_post( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); if let Some(cookie) = new_cookie { ( @@ -1026,10 +954,7 @@ pub async fn authorize_select( ); } }; - let is_verified = user.email_verified - || user.discord_verified - || user.telegram_verified - || user.signal_verified; + let is_verified = user.channel_verification.has_any_verified(); if !is_verified { return json_error( StatusCode::FORBIDDEN, @@ -1068,14 +993,13 @@ pub async fn authorize_select( .await { Ok(challenge) => { - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), user.id, &challenge.code, - &hostname, + hostname, ) .await { @@ -1169,7 +1093,7 @@ pub async fn authorize_select( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); Json(serde_json::json!({ "redirect_uri": redirect_url @@ -1193,10 +1117,10 @@ fn build_success_redirect( '?' }; redirect_url.push(separator); - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_host = pds_hostname(); redirect_url.push_str(&format!( "iss={}", - url_encode(&format!("https://{}", pds_hostname)) + url_encode(&format!("https://{}", pds_host)) )); if let Some(req_state) = state { redirect_url.push_str(&format!("&state={}", url_encode(req_state))); @@ -1211,10 +1135,10 @@ fn build_intermediate_redirect_url( state: Option<&str>, response_mode: Option<&str>, ) -> String { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_host = pds_hostname(); let mut url = format!( "https://{}/oauth/authorize/redirect?redirect_uri={}&code={}", - pds_hostname, + pds_host, url_encode(redirect_uri), url_encode(code) ); @@ -1459,29 +1383,27 @@ pub async fn consent_get( ); } }; - let flow_state = AuthFlowState::from_request_data(&request_data); - - if let Some(err_response) = validate_auth_flow_state(&flow_state, true) { - if flow_state.is_expired() { + let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) { + Ok(flow) => match flow.require_user() { + Ok(u) => u, + Err(_) => { + return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); + } + }, + Err(_) => { let _ = state .oauth_repo .delete_authorization_request(&consent_request_id) .await; - } - return err_response; - } - - let did_str = flow_state.did().unwrap().to_string(); - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => { return json_error( StatusCode::BAD_REQUEST, "invalid_request", - "Invalid DID format in request.", + "Authorization request has expired", ); } }; + + let did = flow_with_user.did().clone(); let client_cache = ClientMetadataCache::new(3600); let client_metadata = client_cache .get(&request_data.parameters.client_id) @@ -1510,7 +1432,7 @@ pub async fn consent_get( }; let effective_scope_str = if let Some(ref grant) = delegation_grant { - crate::delegation::intersect_scopes(requested_scope_str, &grant.granted_scopes) + crate::delegation::intersect_scopes(requested_scope_str, grant.granted_scopes.as_str()) } else { requested_scope_str.to_string() }; @@ -1609,7 +1531,7 @@ pub async fn consent_get( let level = if let Some(ref grant) = delegation_grant { let preset = crate::delegation::SCOPE_PRESETS .iter() - .find(|p| p.scopes == grant.granted_scopes); + .find(|p| p.scopes == grant.granted_scopes.as_str()); preset .map(|p| p.label.to_string()) .unwrap_or_else(|| "Custom".to_string()) @@ -1635,7 +1557,7 @@ pub async fn consent_get( logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()), scopes, show_consent, - did: did_str, + did: did.to_string(), handle: account_handle, is_delegation, controller_did: controller_did_resp, @@ -1676,34 +1598,27 @@ pub async fn consent_post( ); } }; - let flow_state = AuthFlowState::from_request_data(&request_data); - - if flow_state.is_expired() { - let _ = state - .oauth_repo - .delete_authorization_request(&consent_post_request_id) - .await; - return json_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "Authorization request has expired", - ); - } - if flow_state.is_pending() { - return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); - } - - let did_str = flow_state.did().unwrap().to_string(); - let did: Did = match did_str.parse() { - Ok(d) => d, + let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) { + Ok(flow) => match flow.require_user() { + Ok(u) => u, + Err(_) => { + return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated"); + } + }, Err(_) => { + let _ = state + .oauth_repo + .delete_authorization_request(&consent_post_request_id) + .await; return json_error( StatusCode::BAD_REQUEST, "invalid_request", - "Invalid DID format", + "Authorization request has expired", ); } }; + + let did = flow_with_user.did().clone(); let original_scope_str = request_data .parameters .scope @@ -1726,7 +1641,7 @@ pub async fn consent_post( }; let effective_scope_str = if let Some(ref grant) = delegation_grant { - crate::delegation::intersect_scopes(original_scope_str, &grant.granted_scopes) + crate::delegation::intersect_scopes(original_scope_str, grant.granted_scopes.as_str()) } else { original_scope_str.to_string() }; @@ -1799,7 +1714,7 @@ pub async fn consent_post( let consent_post_device_id = request_data .device_id .as_ref() - .map(|d| DeviceIdType::from(d.clone())); + .map(|d| DeviceIdType::from(d.0.clone())); let consent_post_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -1823,7 +1738,7 @@ pub async fn consent_post( redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); tracing::info!( intermediate_url = %intermediate_url, @@ -1835,6 +1750,7 @@ pub async fn consent_post( pub async fn authorize_2fa_post( State(state): State, + _rate_limit: OAuthRateLimited, headers: HeaderMap, Json(form): Json, ) -> Response { @@ -1848,18 +1764,6 @@ pub async fn authorize_2fa_post( ) .into_response() }; - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded"); - return json_error( - StatusCode::TOO_MANY_REQUESTS, - "RateLimitExceeded", - "Too many attempts. Please try again later.", - ); - } let twofa_post_request_id = RequestId::from(form.request_uri.clone()); let request_data = match state .oauth_repo @@ -1956,7 +1860,7 @@ pub async fn authorize_2fa_post( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); return Json(serde_json::json!({ "redirect_uri": redirect_url @@ -1990,17 +1894,16 @@ pub async fn authorize_2fa_post( "No 2FA challenge found. Please start over.", ); } - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &did) - .await - { - tracing::warn!(did = %did, "TOTP verification rate limit exceeded"); - return json_error( - StatusCode::TOO_MANY_REQUESTS, - "RateLimitExceeded", - "Too many verification attempts. Please try again in a few minutes.", - ); - } + let _rate_proof = match check_user_rate_limit::(&state, &did).await { + Ok(proof) => proof, + Err(_) => { + return json_error( + StatusCode::TOO_MANY_REQUESTS, + "RateLimitExceeded", + "Too many verification attempts. Please try again in a few minutes.", + ); + } + }; let totp_valid = crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; if !totp_valid { @@ -2065,7 +1968,7 @@ pub async fn authorize_2fa_post( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); Json(serde_json::json!({ "redirect_uri": redirect_url @@ -2089,23 +1992,13 @@ pub async fn check_user_has_passkeys( State(state): State, Query(query): Query, ) -> Response { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let normalized_identifier = query.identifier.trim(); - let normalized_identifier = normalized_identifier - .strip_prefix('@') - .unwrap_or(normalized_identifier); - let normalized_identifier = if let Some(bare_handle) = - normalized_identifier.strip_suffix(&format!(".{}", hostname_for_handles)) - { - bare_handle.to_string() - } else { - normalized_identifier.to_string() - }; + let hostname_for_handles = pds_hostname_without_port(); + let bare_identifier = + BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles); let user = state .user_repo - .get_login_check_by_handle_or_email(&normalized_identifier) + .get_login_check_by_handle_or_email(bare_identifier.as_str()) .await; let has_passkeys = match user { @@ -2131,21 +2024,13 @@ pub async fn check_user_security_status( State(state): State, Query(query): Query, ) -> Response { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let identifier = query.identifier.trim(); - let identifier = identifier.strip_prefix('@').unwrap_or(identifier); - let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") { - identifier.to_string() - } else if !identifier.contains('.') { - format!("{}.{}", identifier.to_lowercase(), hostname_for_handles) - } else { - identifier.to_lowercase() - }; + let hostname_for_handles = pds_hostname_without_port(); + let normalized_identifier = + NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles); let user = state .user_repo - .get_login_check_by_handle_or_email(&normalized_identifier) + .get_login_check_by_handle_or_email(normalized_identifier.as_str()) .await; let (has_passkeys, has_totp, has_password, is_delegated, did): ( @@ -2200,26 +2085,9 @@ pub struct PasskeyStartResponse { pub async fn passkey_start( State(state): State, - headers: HeaderMap, + _rate_limit: OAuthRateLimited, Json(form): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - - if !state - .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth passkey rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "error": "RateLimitExceeded", - "error_description": "Too many login attempts. Please try again later." - })), - ) - .into_response(); - } - let passkey_start_request_id = RequestId::from(form.request_uri.clone()); let request_data = match state .oauth_repo @@ -2264,23 +2132,13 @@ pub async fn passkey_start( .into_response(); } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); - let normalized_username = form.identifier.trim(); - let normalized_username = normalized_username - .strip_prefix('@') - .unwrap_or(normalized_username); - let normalized_username = if normalized_username.contains('@') { - normalized_username.to_string() - } else if !normalized_username.contains('.') { - format!("{}.{}", normalized_username, hostname_for_handles) - } else { - normalized_username.to_string() - }; + let hostname_for_handles = pds_hostname_without_port(); + let normalized_username = + NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); let user = match state .user_repo - .get_login_info_by_handle_or_email(&normalized_username) + .get_login_info_by_handle_or_email(normalized_username.as_str()) .await { Ok(Some(u)) => u, @@ -2328,10 +2186,7 @@ pub async fn passkey_start( .into_response(); } - let is_verified = user.email_verified - || user.discord_verified - || user.telegram_verified - || user.signal_verified; + let is_verified = user.channel_verification.has_any_verified(); if !is_verified { return ( @@ -2386,22 +2241,7 @@ pub async fn passkey_start( .into_response(); } - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - tracing::error!(error = %e, "Failed to create WebAuthn config"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": "WebAuthn configuration failed." - })), - ) - .into_response(); - } - }; - - let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { + let (rcr, auth_state) = match state.webauthn_config.start_authentication(passkeys) { Ok(result) => result, Err(e) => { tracing::error!(error = %e, "Failed to start passkey authentication"); @@ -2680,23 +2520,10 @@ pub async fn passkey_finish( } }; - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - tracing::error!(error = %e, "Failed to create WebAuthn config"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": "WebAuthn configuration failed." - })), - ) - .into_response(); - } - }; - - let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { + let auth_result = match state + .webauthn_config + .finish_authentication(&credential, &auth_state) + { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); @@ -2769,14 +2596,13 @@ pub async fn passkey_finish( .await { Ok(challenge) => { - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = enqueue_2fa_code( state.user_repo.as_ref(), state.infra_repo.as_ref(), user.id, &challenge.code, - &hostname, + hostname, ) .await { @@ -2859,7 +2685,7 @@ pub async fn passkey_finish( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); Json(serde_json::json!({ @@ -2884,8 +2710,6 @@ pub async fn authorize_passkey_start( State(state): State, Query(query): Query, ) -> Response { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let auth_passkey_start_request_id = RequestId::from(query.request_uri.clone()); let request_data = match state .oauth_repo @@ -2994,19 +2818,7 @@ pub async fn authorize_passkey_start( .into_response(); } - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - tracing::error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), - ) - .into_response(); - } - }; - - let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { + let (rcr, auth_state) = match state.webauthn_config.start_authentication(passkeys) { Ok(result) => result, Err(e) => { tracing::error!("Failed to start passkey authentication: {:?}", e); @@ -3063,7 +2875,7 @@ pub async fn authorize_passkey_finish( headers: HeaderMap, Json(form): Json, ) -> Response { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let passkey_finish_request_id = RequestId::from(form.request_uri.clone()); let request_data = match state @@ -3193,19 +3005,10 @@ pub async fn authorize_passkey_finish( } }; - let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { - Ok(w) => w, - Err(e) => { - tracing::error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), - ) - .into_response(); - } - }; - - let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { + let auth_result = match state + .webauthn_config + .finish_authentication(&credential, &auth_state) + { Ok(r) => r, Err(e) => { tracing::warn!("Passkey authentication failed: {:?}", e); @@ -3292,7 +3095,7 @@ pub async fn authorize_passkey_finish( state.infra_repo.as_ref(), user.id, &challenge.code, - &pds_hostname, + pds_hostname, ) .await { @@ -3347,25 +3150,9 @@ pub struct RegisterCompleteInput { pub async fn register_complete( State(state): State, - headers: HeaderMap, + _rate_limit: OAuthRateLimited, Json(form): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - - if !state - .check_rate_limit(RateLimitKind::OAuthRegisterComplete, &client_ip) - .await - { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "error": "RateLimitExceeded", - "error_description": "Too many attempts. Please try again later." - })), - ) - .into_response(); - } - let did = Did::from(form.did.clone()); let request_id = RequestId::from(form.request_uri.clone()); @@ -3417,7 +3204,7 @@ pub async fn register_complete( .into_response(); } - if request_data.parameters.prompt.as_deref() != Some("create") { + if request_data.parameters.prompt != Some(Prompt::Create) { tracing::warn!( request_uri = %form.request_uri, prompt = ?request_data.parameters.prompt, @@ -3506,12 +3293,7 @@ pub async fn register_complete( } let is_verified = match state.user_repo.get_session_info_by_did(&did).await { - Ok(Some(info)) => { - info.email_verified - || info.discord_verified - || info.telegram_verified - || info.signal_verified - } + Ok(Some(info)) => info.channel_verification.has_any_verified(), Ok(None) => { return ( StatusCode::FORBIDDEN, @@ -3636,7 +3418,7 @@ pub async fn register_complete( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), - request_data.parameters.response_mode.as_deref(), + request_data.parameters.response_mode.map(|m| m.as_str()), ); Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() } @@ -3662,9 +3444,9 @@ pub async fn establish_session( None => { let new_id = DeviceId::generate(); let device_data = DeviceData { - session_id: SessionId::generate().0, + session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), - ip_address: extract_client_ip(&headers), + ip_address: extract_client_ip(&headers, None), last_seen_at: Utc::now(), }; let device_typed = DeviceIdType::from(new_id.0.clone()); diff --git a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs index 7d36a52..84e9c23 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs @@ -1,12 +1,13 @@ use crate::auth::{Active, Auth}; use crate::delegation::DelegationActionType; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit}; +use crate::state::AppState; use crate::types::PlainPassword; use crate::util::extract_client_ip; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; @@ -35,26 +36,11 @@ pub struct DelegationAuthResponse { pub async fn delegation_auth( State(state): State, + rate_limit: OAuthRateLimited, headers: HeaderMap, Json(form): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::Login, &client_ip) - .await - { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("Too many login attempts. Please try again later.".to_string()), - }), - ) - .into_response(); - } - + let client_ip = rate_limit.client_ip(); let request_id = RequestId::from(form.request_uri.clone()); let request = match state .oauth_repo @@ -82,30 +68,29 @@ pub async fn delegation_auth( } }; - let delegated_did_str = match form.delegated_did.as_ref().or(request.did.as_ref()) { - Some(did) => did.clone(), - None => { - return Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("No delegated account selected".to_string()), - }) - .into_response(); - } - }; - - let delegated_did: Did = match delegated_did_str.parse() { - Ok(d) => d, - Err(_) => { - return Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("Invalid delegated DID".to_string()), - }) - .into_response(); + let delegated_did: Did = if let Some(did_str) = form.delegated_did.as_ref() { + match did_str.parse() { + Ok(d) => d, + Err(_) => { + return Json(DelegationAuthResponse { + success: false, + needs_totp: None, + redirect_uri: None, + error: Some("Invalid delegated DID".to_string()), + }) + .into_response(); + } } + } else if let Some(did) = request.did.as_ref() { + did.clone() + } else { + return Json(DelegationAuthResponse { + success: false, + needs_totp: None, + redirect_uri: None, + error: Some("No delegated account selected".to_string()), + }) + .into_response(); }; let controller_did: Did = match form.controller_did.parse() { @@ -249,7 +234,6 @@ pub async fn delegation_auth( .into_response(); } - let ip = extract_client_ip(&headers); let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) @@ -266,7 +250,7 @@ pub async fn delegation_auth( "client_id": request.client_id, "granted_scopes": grant.granted_scopes })), - Some(&ip), + Some(client_ip), user_agent.as_deref(), ) .await; @@ -291,26 +275,11 @@ pub struct DelegationTotpSubmit { pub async fn delegation_totp_verify( State(state): State, + rate_limit: OAuthRateLimited, headers: HeaderMap, Json(form): Json, ) -> Response { - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::TotpVerify, &client_ip) - .await - { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("Too many verification attempts. Please try again later.".to_string()), - }), - ) - .into_response(); - } - + let client_ip = rate_limit.client_ip(); let totp_request_id = RequestId::from(form.request_uri.clone()); let request = match state .oauth_repo @@ -420,7 +389,6 @@ pub async fn delegation_totp_verify( .into_response(); } - let ip = extract_client_ip(&headers); let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) @@ -437,7 +405,7 @@ pub async fn delegation_totp_verify( "client_id": request.client_id, "granted_scopes": grant.granted_scopes })), - Some(&ip), + Some(client_ip), user_agent.as_deref(), ) .await; @@ -564,7 +532,7 @@ pub async fn delegation_auth_token( .into_response(); } - let ip = extract_client_ip(&headers); + let ip = extract_client_ip(&headers, None); let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index 9a61db1..59cb828 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -1,5 +1,6 @@ use crate::oauth::jwks::{JwkSet, create_jwk_set}; use crate::state::AppState; +use crate::util::pds_hostname; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; @@ -57,7 +58,7 @@ pub struct AuthorizationServerMetadata { pub async fn oauth_protected_resource( State(_state): State, ) -> Json { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let public_url = format!("https://{}", pds_hostname); Json(ProtectedResourceMetadata { resource: public_url.clone(), @@ -71,7 +72,7 @@ pub async fn oauth_protected_resource( pub async fn oauth_authorization_server( State(_state): State, ) -> Json { - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let issuer = format!("https://{}", pds_hostname); Json(AuthorizationServerMetadata { issuer: issuer.clone(), diff --git a/crates/tranquil-pds/src/oauth/endpoints/par.rs b/crates/tranquil-pds/src/oauth/endpoints/par.rs index c6f06fa..3d97df5 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/par.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/par.rs @@ -1,9 +1,10 @@ use crate::oauth::{ - AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, OAuthError, RequestData, - RequestId, + AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod, + OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType, scopes::{ParsedScope, parse_scope}, }; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{OAuthParLimit, OAuthRateLimited}; +use crate::state::AppState; use axum::body::Bytes; use axum::{Json, extract::State, http::HeaderMap}; use chrono::{Duration, Utc}; @@ -49,6 +50,7 @@ pub struct ParResponse { pub async fn pushed_authorization_request( State(state): State, + _rate_limit: OAuthRateLimited, headers: HeaderMap, body: Bytes, ) -> Result<(axum::http::StatusCode, Json), OAuthError> { @@ -70,30 +72,14 @@ pub async fn pushed_authorization_request( .to_string(), )); }; - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::OAuthPar, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded"); - return Err(OAuthError::RateLimited); - } - if request.response_type != "code" { - return Err(OAuthError::InvalidRequest( - "response_type must be 'code'".to_string(), - )); - } + let response_type = parse_response_type(&request.response_type)?; let code_challenge = request .code_challenge .as_ref() .filter(|s| !s.is_empty()) .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?; - let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or(""); - if code_challenge_method != "S256" { - return Err(OAuthError::InvalidRequest( - "code_challenge_method must be 'S256'".to_string(), - )); - } + let code_challenge_method = + parse_code_challenge_method(request.code_challenge_method.as_deref())?; let client_cache = ClientMetadataCache::new(3600); let client_metadata = client_cache.get(&request.client_id).await?; client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?; @@ -101,25 +87,16 @@ pub async fn pushed_authorization_request( let validated_scope = validate_scope(&request.scope, &client_metadata)?; let request_id = RequestId::generate(); let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS); - let response_mode = match request.response_mode.as_deref() { - Some("fragment") => Some("fragment".to_string()), - Some("query") | None => None, - Some(mode) => { - return Err(OAuthError::InvalidRequest(format!( - "Unsupported response_mode: {}", - mode - ))); - } - }; - let prompt = validate_prompt(&request.prompt)?; + let response_mode = parse_response_mode(request.response_mode.as_deref())?; + let prompt = parse_prompt(request.prompt.as_deref())?; let parameters = AuthorizationRequestParameters { - response_type: request.response_type, + response_type, client_id: request.client_id.clone(), redirect_uri: request.redirect_uri, scope: validated_scope, state: request.state, code_challenge: code_challenge.clone(), - code_challenge_method: code_challenge_method.to_string(), + code_challenge_method, response_mode, login_hint: request.login_hint, dpop_jkt: request.dpop_jkt, @@ -266,21 +243,52 @@ fn scope_matches(client_scope: &str, requested_scope: &str) -> bool { false } -fn validate_prompt(prompt: &Option) -> Result, OAuthError> { - const VALID_PROMPTS: &[&str] = &["none", "login", "consent", "select_account", "create"]; +fn parse_response_type(value: &str) -> Result { + match value { + "code" => Ok(ResponseType::Code), + other => Err(OAuthError::InvalidRequest(format!( + "response_type must be 'code', got '{}'", + other + ))), + } +} - match prompt { - None => Ok(None), - Some(p) if p.is_empty() => Ok(None), - Some(p) => { - if VALID_PROMPTS.contains(&p.as_str()) { - Ok(Some(p.clone())) - } else { - Err(OAuthError::InvalidRequest(format!( - "Unsupported prompt value: {}", - p - ))) - } - } +fn parse_code_challenge_method(value: Option<&str>) -> Result { + match value { + Some("S256") | None => Ok(CodeChallengeMethod::S256), + Some("plain") => Err(OAuthError::InvalidRequest( + "code_challenge_method 'plain' is not allowed, use 'S256'".to_string(), + )), + Some(other) => Err(OAuthError::InvalidRequest(format!( + "Unsupported code_challenge_method: {}", + other + ))), + } +} + +fn parse_response_mode(value: Option<&str>) -> Result, OAuthError> { + match value { + None | Some("query") => Ok(None), + Some("fragment") => Ok(Some(ResponseMode::Fragment)), + Some("form_post") => Ok(Some(ResponseMode::FormPost)), + Some(other) => Err(OAuthError::InvalidRequest(format!( + "Unsupported response_mode: {}", + other + ))), + } +} + +fn parse_prompt(value: Option<&str>) -> Result, OAuthError> { + match value { + None | Some("") => Ok(None), + Some("none") => Ok(Some(Prompt::None)), + Some("login") => Ok(Some(Prompt::Login)), + Some("consent") => Ok(Some(Prompt::Consent)), + Some("select_account") => Ok(Some(Prompt::SelectAccount)), + Some("create") => Ok(Some(Prompt::Create)), + Some(other) => Err(OAuthError::InvalidRequest(format!( + "Unsupported prompt value: {}", + other + ))), } } diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs index 0b3cf85..c5cf7eb 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs @@ -3,13 +3,14 @@ use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest}; use crate::config::AuthConfig; use crate::delegation::intersect_scopes; use crate::oauth::{ - AuthFlowState, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, - TokenData, TokenId, + AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData, + TokenId, db::{enforce_token_limit_for_user, lookup_refresh_token}, scopes::expand_include_scopes, verify_client_auth, }; use crate::state::AppState; +use crate::util::pds_hostname; use axum::Json; use axum::http::HeaderMap; use chrono::{Duration, Utc}; @@ -51,26 +52,21 @@ pub async fn handle_authorization_code_grant( .map_err(crate::oauth::db_err_to_oauth)? .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; - let flow_state = AuthFlowState::from_request_data(&auth_request); - if flow_state.is_expired() { - return Err(OAuthError::InvalidGrant( - "Authorization code has expired".to_string(), - )); - } - if !flow_state.can_exchange() { - return Err(OAuthError::InvalidGrant( - "Authorization not completed".to_string(), - )); - } + let flow = AuthFlow::from_request_data(auth_request) + .map_err(|_| OAuthError::InvalidGrant("Authorization code has expired".to_string()))?; + + let authorized = flow + .require_authorized() + .map_err(|_| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; if let Some(request_client_id) = &request.client_auth.client_id - && request_client_id != &auth_request.client_id + && request_client_id != &authorized.client_id { return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); } - let did = flow_state.did().unwrap().to_string(); + let did = authorized.did.to_string(); let client_metadata_cache = ClientMetadataCache::new(3600); - let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?; + let client_metadata = client_metadata_cache.get(&authorized.client_id).await?; let client_auth = if let (Some(assertion), Some(assertion_type)) = ( &request.client_auth.client_assertion, &request.client_auth.client_assertion_type, @@ -91,9 +87,9 @@ pub async fn handle_authorization_code_grant( ClientAuth::None }; verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?; - verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; + verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?; if let Some(req_redirect_uri) = &redirect_uri - && req_redirect_uri != &auth_request.parameters.redirect_uri + && req_redirect_uri != &authorized.parameters.redirect_uri { return Err(OAuthError::InvalidGrant( "redirect_uri mismatch".to_string(), @@ -102,8 +98,7 @@ pub async fn handle_authorization_code_grant( let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; if !state @@ -116,7 +111,7 @@ pub async fn handle_authorization_code_grant( "DPoP proof has already been used".to_string(), )); } - if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt + if let Some(expected_jkt) = &authorized.parameters.dpop_jkt && result.jkt.as_str() != expected_jkt { return Err(OAuthError::InvalidDpopProof( @@ -124,7 +119,7 @@ pub async fn handle_authorization_code_grant( )); } Some(result.jkt.as_str().to_string()) - } else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() { + } else if authorized.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() { return Err(OAuthError::UseDpopNonce( DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes()).generate_nonce(), )); @@ -135,7 +130,7 @@ pub async fn handle_authorization_code_grant( let refresh_token = RefreshToken::generate(); let now = Utc::now(); - let (raw_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { + let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did { let did_parsed: Did = did .parse() .map_err(|_| OAuthError::InvalidRequest("Invalid DID format".to_string()))?; @@ -149,15 +144,11 @@ pub async fn handle_authorization_code_grant( .ok() .flatten(); let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); - let requested = auth_request - .parameters - .scope - .as_deref() - .unwrap_or("atproto"); - let intersected = intersect_scopes(requested, &granted_scopes); + let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); + let intersected = intersect_scopes(requested, granted_scopes.as_str()); (Some(intersected), Some(controller.clone())) } else { - (auth_request.parameters.scope.clone(), None) + (authorized.parameters.scope.clone(), None) }; let final_scope = if let Some(ref scope) = raw_scope { @@ -177,27 +168,30 @@ pub async fn handle_authorization_code_grant( final_scope.as_deref(), controller_did.as_deref(), )?; - let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None); + let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None); let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) { REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC } else { REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL }; - let mut stored_parameters = auth_request.parameters.clone(); + let mut stored_parameters = authorized.parameters.clone(); stored_parameters.dpop_jkt = dpop_jkt.clone(); + let did_typed: Did = did + .parse() + .map_err(|_| OAuthError::InvalidRequest("Invalid DID format".to_string()))?; let token_data = TokenData { - did: did.clone(), - token_id: token_id.0.clone(), + did: did_typed, + token_id: token_id.clone(), created_at: now, updated_at: now, expires_at: now + Duration::days(refresh_expiry_days), - client_id: auth_request.client_id.clone(), + client_id: authorized.client_id.clone(), client_auth: stored_client_auth, - device_id: auth_request.device_id, + device_id: authorized.device_id.clone(), parameters: stored_parameters, details: None, code: None, - current_refresh_token: Some(refresh_token.0.clone()), + current_refresh_token: Some(refresh_token.clone()), scope: final_scope.clone(), controller_did: controller_did.clone(), }; @@ -209,7 +203,7 @@ pub async fn handle_authorization_code_grant( tracing::info!( did = %did, token_id = %token_id.0, - client_id = %auth_request.client_id, + client_id = %authorized.client_id, "Authorization code grant completed, token created" ); tokio::spawn({ @@ -280,11 +274,11 @@ pub async fn handle_refresh_token_grant( ); let dpop_jkt = token_data.parameters.dpop_jkt.as_deref(); let access_token = create_access_token_with_delegation( - &token_data.token_id, - &token_data.did, + &token_data.token_id.0, + token_data.did.as_str(), dpop_jkt, token_data.scope.as_deref(), - token_data.controller_did.as_deref(), + token_data.controller_did.as_ref().map(|d| d.as_str()), )?; let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); @@ -296,9 +290,9 @@ pub async fn handle_refresh_token_grant( access_token, token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, - refresh_token: token_data.current_refresh_token, + refresh_token: token_data.current_refresh_token.map(|r| r.0), scope: token_data.scope, - sub: Some(token_data.did), + sub: Some(token_data.did.to_string()), }), )); } @@ -337,8 +331,7 @@ pub async fn handle_refresh_token_grant( let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - let pds_hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; if !state @@ -385,11 +378,11 @@ pub async fn handle_refresh_token_grant( "Refresh token rotated successfully" ); let access_token = create_access_token_with_delegation( - &token_data.token_id, - &token_data.did, + &token_data.token_id.0, + token_data.did.as_str(), dpop_jkt.as_deref(), token_data.scope.as_deref(), - token_data.controller_did.as_deref(), + token_data.controller_did.as_ref().map(|d| d.as_str()), )?; let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); @@ -403,7 +396,7 @@ pub async fn handle_refresh_token_grant( expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, refresh_token: Some(new_refresh_token.0), scope: token_data.scope, - sub: Some(token_data.did), + sub: Some(token_data.did.to_string()), }), )) } diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs index f145987..f74e6e6 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs @@ -1,5 +1,6 @@ use crate::config::AuthConfig; use crate::oauth::OAuthError; +use crate::util::pds_hostname; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; @@ -51,7 +52,7 @@ pub fn create_access_token_with_delegation( ) -> Result { use serde_json::json; let jti = uuid::Uuid::new_v4().to_string(); - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let issuer = format!("https://{}", pds_hostname); let now = Utc::now().timestamp(); let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs b/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs index 6f40473..07f5c87 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs @@ -1,8 +1,10 @@ use super::helpers::extract_token_claims; use crate::oauth::OAuthError; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{OAuthIntrospectLimit, OAuthRateLimited}; +use crate::state::AppState; +use crate::util::pds_hostname; use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::StatusCode; use axum::{Form, Json}; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -17,17 +19,9 @@ pub struct RevokeRequest { pub async fn revoke_token( State(state): State, - headers: HeaderMap, + _rate_limit: OAuthRateLimited, Form(request): Form, ) -> Result { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded"); - return Err(OAuthError::RateLimited); - } if let Some(token) = &request.token { let refresh_token = RefreshToken::from(token.clone()); if let Some((db_id, _)) = state @@ -89,17 +83,9 @@ pub struct IntrospectResponse { pub async fn introspect_token( State(state): State, - headers: HeaderMap, + _rate_limit: OAuthRateLimited, Form(request): Form, ) -> Result, OAuthError> { - let client_ip = crate::rate_limit::extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded"); - return Err(OAuthError::RateLimited); - } let inactive_response = IntrospectResponse { active: false, scope: None, @@ -126,7 +112,7 @@ pub async fn introspect_token( if token_data.expires_at < Utc::now() { return Ok(Json(inactive_response)); } - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = pds_hostname(); let issuer = format!("https://{}", pds_hostname); Ok(Json(IntrospectResponse { active: true, @@ -141,7 +127,7 @@ pub async fn introspect_token( exp: Some(token_info.exp), iat: Some(token_info.iat), nbf: Some(token_info.iat), - sub: Some(token_data.did), + sub: Some(token_data.did.to_string()), aud: Some(issuer.clone()), iss: Some(issuer), jti: Some(token_info.jti), diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs b/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs index e1110c7..de3db61 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs @@ -4,7 +4,8 @@ mod introspect; mod types; use crate::oauth::OAuthError; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{OAuthRateLimited, OAuthTokenLimit}; +use crate::state::AppState; use axum::body::Bytes; use axum::{Json, extract::State, http::HeaderMap}; @@ -17,23 +18,9 @@ pub use types::{ ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest, }; -fn extract_client_ip(headers: &HeaderMap) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - "unknown".to_string() -} - pub async fn token_endpoint( State(state): State, + _rate_limit: OAuthRateLimited, headers: HeaderMap, body: Bytes, ) -> Result<(HeaderMap, Json), OAuthError> { @@ -53,16 +40,6 @@ pub async fn token_endpoint( .to_string(), )); }; - let client_ip = extract_client_ip(&headers); - if !state - .check_rate_limit(RateLimitKind::OAuthToken, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded"); - return Err(OAuthError::InvalidRequest( - "Too many requests. Please try again later.".to_string(), - )); - } let dpop_proof = headers .get("DPoP") .and_then(|v| v.to_str().ok()) diff --git a/crates/tranquil-pds/src/oauth/mod.rs b/crates/tranquil-pds/src/oauth/mod.rs index d34cdbb..f184714 100644 --- a/crates/tranquil-pds/src/oauth/mod.rs +++ b/crates/tranquil-pds/src/oauth/mod.rs @@ -10,13 +10,15 @@ pub fn db_err_to_oauth(err: tranquil_db::DbError) -> OAuthError { } pub use tranquil_oauth::{ - AuthFlowState, AuthorizationRequestParameters, AuthorizationServerMetadata, - AuthorizedClientData, ClientAuth, ClientMetadata, ClientMetadataCache, Code, DPoPClaims, - DPoPJwk, DPoPProofHeader, DPoPProofPayload, DPoPVerifier, DPoPVerifyResult, DeviceData, - DeviceId, JwkPublicKey, Jwks, OAuthClientMetadata, OAuthError, ParResponse, - ProtectedResourceMetadata, RefreshToken, RefreshTokenState, RequestData, RequestId, SessionId, - TokenData, TokenId, TokenRequest, TokenResponse, compute_access_token_hash, - compute_jwk_thumbprint, verify_client_auth, + AuthFlow, AuthFlowWithUser, AuthorizationRequestParameters, AuthorizationServerMetadata, + AuthorizedClientData, ClientAuth, ClientMetadata, ClientMetadataCache, Code, + CodeChallengeMethod, DPoPClaims, DPoPJwk, DPoPProofHeader, DPoPProofPayload, DPoPVerifier, + DPoPVerifyResult, DeviceData, DeviceId, FlowAuthenticated, FlowAuthorized, FlowExpired, + FlowNotAuthenticated, FlowNotAuthorized, FlowPending, JwkPublicKey, Jwks, OAuthClientMetadata, + OAuthError, ParResponse, Prompt, ProtectedResourceMetadata, RefreshToken, RefreshTokenState, + RequestData, RequestId, ResponseMode, ResponseType, SessionId, TokenData, TokenId, + TokenRequest, TokenResponse, compute_access_token_hash, compute_jwk_thumbprint, + verify_client_auth, }; pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions}; diff --git a/crates/tranquil-pds/src/oauth/verify.rs b/crates/tranquil-pds/src/oauth/verify.rs index 39ae2f7..6681367 100644 --- a/crates/tranquil-pds/src/oauth/verify.rs +++ b/crates/tranquil-pds/src/oauth/verify.rs @@ -20,12 +20,12 @@ use crate::config::AuthConfig; use crate::state::AppState; pub struct OAuthTokenInfo { - pub did: String, - pub token_id: String, - pub client_id: String, + pub did: Did, + pub token_id: TokenId, + pub client_id: ClientId, pub scope: Option, pub dpop_jkt: Option, - pub controller_did: Option, + pub controller_did: Option, } pub struct VerifyResult { @@ -48,7 +48,7 @@ pub async fn verify_oauth_access_token( has_dpop_proof = dpop_proof.is_some(), "Verifying OAuth access token" ); - let token_id = TokenId::from(token_info.token_id.clone()); + let token_id = token_info.token_id.clone(); let token_data = oauth_repo .get_token_by_id(&token_id) .await @@ -154,16 +154,18 @@ pub fn extract_oauth_token_info(token: &str) -> Result Result()) + .transpose() + .map_err(|_| { + OAuthError::InvalidToken("Invalid act.sub claim (not a valid DID)".to_string()) + })?; Ok(OAuthTokenInfo { did, token_id, diff --git a/crates/tranquil-pds/src/rate_limit/extractor.rs b/crates/tranquil-pds/src/rate_limit/extractor.rs new file mode 100644 index 0000000..b8c3af2 --- /dev/null +++ b/crates/tranquil-pds/src/rate_limit/extractor.rs @@ -0,0 +1,272 @@ +use std::marker::PhantomData; + +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponse, Response}, +}; + +use crate::api::error::ApiError; +use crate::oauth::OAuthError; +use crate::state::{AppState, RateLimitKind}; +use crate::util::extract_client_ip; + +pub trait RateLimitPolicy: Send + Sync + 'static { + const KIND: RateLimitKind; +} + +pub struct LoginLimit; +impl RateLimitPolicy for LoginLimit { + const KIND: RateLimitKind = RateLimitKind::Login; +} + +pub struct AccountCreationLimit; +impl RateLimitPolicy for AccountCreationLimit { + const KIND: RateLimitKind = RateLimitKind::AccountCreation; +} + +pub struct PasswordResetLimit; +impl RateLimitPolicy for PasswordResetLimit { + const KIND: RateLimitKind = RateLimitKind::PasswordReset; +} + +pub struct ResetPasswordLimit; +impl RateLimitPolicy for ResetPasswordLimit { + const KIND: RateLimitKind = RateLimitKind::ResetPassword; +} + +pub struct RefreshSessionLimit; +impl RateLimitPolicy for RefreshSessionLimit { + const KIND: RateLimitKind = RateLimitKind::RefreshSession; +} + +pub struct OAuthTokenLimit; +impl RateLimitPolicy for OAuthTokenLimit { + const KIND: RateLimitKind = RateLimitKind::OAuthToken; +} + +pub struct OAuthAuthorizeLimit; +impl RateLimitPolicy for OAuthAuthorizeLimit { + const KIND: RateLimitKind = RateLimitKind::OAuthAuthorize; +} + +pub struct OAuthParLimit; +impl RateLimitPolicy for OAuthParLimit { + const KIND: RateLimitKind = RateLimitKind::OAuthPar; +} + +pub struct OAuthIntrospectLimit; +impl RateLimitPolicy for OAuthIntrospectLimit { + const KIND: RateLimitKind = RateLimitKind::OAuthIntrospect; +} + +pub struct AppPasswordLimit; +impl RateLimitPolicy for AppPasswordLimit { + const KIND: RateLimitKind = RateLimitKind::AppPassword; +} + +pub struct EmailUpdateLimit; +impl RateLimitPolicy for EmailUpdateLimit { + const KIND: RateLimitKind = RateLimitKind::EmailUpdate; +} + +pub struct TotpVerifyLimit; +impl RateLimitPolicy for TotpVerifyLimit { + const KIND: RateLimitKind = RateLimitKind::TotpVerify; +} + +pub struct HandleUpdateLimit; +impl RateLimitPolicy for HandleUpdateLimit { + const KIND: RateLimitKind = RateLimitKind::HandleUpdate; +} + +pub struct HandleUpdateDailyLimit; +impl RateLimitPolicy for HandleUpdateDailyLimit { + const KIND: RateLimitKind = RateLimitKind::HandleUpdateDaily; +} + +pub struct VerificationCheckLimit; +impl RateLimitPolicy for VerificationCheckLimit { + const KIND: RateLimitKind = RateLimitKind::VerificationCheck; +} + +pub struct SsoInitiateLimit; +impl RateLimitPolicy for SsoInitiateLimit { + const KIND: RateLimitKind = RateLimitKind::SsoInitiate; +} + +pub struct SsoCallbackLimit; +impl RateLimitPolicy for SsoCallbackLimit { + const KIND: RateLimitKind = RateLimitKind::SsoCallback; +} + +pub struct SsoUnlinkLimit; +impl RateLimitPolicy for SsoUnlinkLimit { + const KIND: RateLimitKind = RateLimitKind::SsoUnlink; +} + +pub struct OAuthRegisterCompleteLimit; +impl RateLimitPolicy for OAuthRegisterCompleteLimit { + const KIND: RateLimitKind = RateLimitKind::OAuthRegisterComplete; +} + +pub trait RateLimitRejection: IntoResponse + Send + 'static { + fn new() -> Self; +} + +pub struct ApiRateLimitRejection; + +impl RateLimitRejection for ApiRateLimitRejection { + fn new() -> Self { + Self + } +} + +impl IntoResponse for ApiRateLimitRejection { + fn into_response(self) -> Response { + ApiError::RateLimitExceeded(None).into_response() + } +} + +pub struct OAuthRateLimitRejection; + +impl RateLimitRejection for OAuthRateLimitRejection { + fn new() -> Self { + Self + } +} + +impl IntoResponse for OAuthRateLimitRejection { + fn into_response(self) -> Response { + OAuthError::RateLimited.into_response() + } +} + +impl From for OAuthError { + fn from(_: OAuthRateLimitRejection) -> Self { + OAuthError::RateLimited + } +} + +pub struct RateLimitedInner { + client_ip: String, + _marker: PhantomData<(P, R)>, +} + +impl RateLimitedInner { + pub fn client_ip(&self) -> &str { + &self.client_ip + } +} + +impl FromRequestParts + for RateLimitedInner +{ + type Rejection = R; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let client_ip = extract_client_ip(&parts.headers, None); + + if !state.check_rate_limit(P::KIND, &client_ip).await { + tracing::warn!( + ip = %client_ip, + kind = ?P::KIND, + "Rate limit exceeded" + ); + return Err(R::new()); + } + + Ok(RateLimitedInner { + client_ip, + _marker: PhantomData, + }) + } +} + +pub type RateLimited

= RateLimitedInner; +pub type OAuthRateLimited

= RateLimitedInner; + +#[derive(Debug)] +pub struct UserRateLimitError { + pub kind: RateLimitKind, + pub message: Option, +} + +impl UserRateLimitError { + pub fn new(kind: RateLimitKind) -> Self { + Self { + kind, + message: None, + } + } + + pub fn with_message(kind: RateLimitKind, message: impl Into) -> Self { + Self { + kind, + message: Some(message.into()), + } + } +} + +impl std::fmt::Display for UserRateLimitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.message { + Some(msg) => write!(f, "{}", msg), + None => write!(f, "Rate limit exceeded for {:?}", self.kind), + } + } +} + +impl std::error::Error for UserRateLimitError {} + +impl IntoResponse for UserRateLimitError { + fn into_response(self) -> Response { + ApiError::RateLimitExceeded(self.message).into_response() + } +} + +pub struct UserRateLimitProof { + _marker: PhantomData

, +} + +impl UserRateLimitProof

{ + fn new() -> Self { + Self { + _marker: PhantomData, + } + } +} + +pub async fn check_user_rate_limit( + state: &AppState, + user_key: &str, +) -> Result, UserRateLimitError> { + if !state.check_rate_limit(P::KIND, user_key).await { + tracing::warn!( + key = %user_key, + kind = ?P::KIND, + "User rate limit exceeded" + ); + return Err(UserRateLimitError::new(P::KIND)); + } + Ok(UserRateLimitProof::new()) +} + +pub async fn check_user_rate_limit_with_message( + state: &AppState, + user_key: &str, + error_message: impl Into, +) -> Result, UserRateLimitError> { + if !state.check_rate_limit(P::KIND, user_key).await { + tracing::warn!( + key = %user_key, + kind = ?P::KIND, + "User rate limit exceeded" + ); + return Err(UserRateLimitError::with_message(P::KIND, error_message)); + } + Ok(UserRateLimitProof::new()) +} diff --git a/crates/tranquil-pds/src/rate_limit.rs b/crates/tranquil-pds/src/rate_limit/mod.rs similarity index 70% rename from crates/tranquil-pds/src/rate_limit.rs rename to crates/tranquil-pds/src/rate_limit/mod.rs index fc533fd..05c9bad 100644 --- a/crates/tranquil-pds/src/rate_limit.rs +++ b/crates/tranquil-pds/src/rate_limit/mod.rs @@ -1,17 +1,13 @@ -use axum::{ - Json, - body::Body, - extract::ConnectInfo, - http::{HeaderMap, Request, StatusCode}, - middleware::Next, - response::{IntoResponse, Response}, -}; +mod extractor; + +pub use extractor::*; + use governor::{ Quota, RateLimiter, clock::DefaultClock, state::{InMemoryState, NotKeyed, keyed::DefaultKeyedStateStore}, }; -use std::{net::SocketAddr, num::NonZeroU32, sync::Arc}; +use std::{num::NonZeroU32, sync::Arc}; pub type KeyedRateLimiter = RateLimiter, DefaultClock>; pub type GlobalRateLimiter = RateLimiter; @@ -166,99 +162,6 @@ impl RateLimiters { } } -pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> String { - if let Some(forwarded) = headers.get("x-forwarded-for") - && let Ok(value) = forwarded.to_str() - && let Some(first_ip) = value.split(',').next() - { - return first_ip.trim().to_string(); - } - - if let Some(real_ip) = headers.get("x-real-ip") - && let Ok(value) = real_ip.to_str() - { - return value.trim().to_string(); - } - - addr.map(|a| a.ip().to_string()) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn rate_limit_response() -> Response { - ( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response() -} - -pub async fn login_rate_limit( - ConnectInfo(addr): ConnectInfo, - axum::extract::State(limiters): axum::extract::State>, - request: Request, - next: Next, -) -> Response { - let client_ip = extract_client_ip(request.headers(), Some(addr)); - - if limiters.login.check_key(&client_ip).is_err() { - tracing::warn!(ip = %client_ip, "Login rate limit exceeded"); - return rate_limit_response(); - } - - next.run(request).await -} - -pub async fn oauth_token_rate_limit( - ConnectInfo(addr): ConnectInfo, - axum::extract::State(limiters): axum::extract::State>, - request: Request, - next: Next, -) -> Response { - let client_ip = extract_client_ip(request.headers(), Some(addr)); - - if limiters.oauth_token.check_key(&client_ip).is_err() { - tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded"); - return rate_limit_response(); - } - - next.run(request).await -} - -pub async fn password_reset_rate_limit( - ConnectInfo(addr): ConnectInfo, - axum::extract::State(limiters): axum::extract::State>, - request: Request, - next: Next, -) -> Response { - let client_ip = extract_client_ip(request.headers(), Some(addr)); - - if limiters.password_reset.check_key(&client_ip).is_err() { - tracing::warn!(ip = %client_ip, "Password reset rate limit exceeded"); - return rate_limit_response(); - } - - next.run(request).await -} - -pub async fn account_creation_rate_limit( - ConnectInfo(addr): ConnectInfo, - axum::extract::State(limiters): axum::extract::State>, - request: Request, - next: Next, -) -> Response { - let client_ip = extract_client_ip(request.headers(), Some(addr)); - - if limiters.account_creation.check_key(&client_ip).is_err() { - tracing::warn!(ip = %client_ip, "Account creation rate limit exceeded"); - return rate_limit_response(); - } - - next.run(request).await -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs index d030b93..46cbbe9 100644 --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -5,12 +5,12 @@ use jacquard_repo::storage::BlockStore; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use tokio::sync::watch; use tokio::time::interval; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tranquil_db_traits::{ - BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository, - UserRepository, + BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SequenceNumber, + SsoRepository, UserRepository, }; use tranquil_types::{AtUri, CidLink, Did}; @@ -22,7 +22,7 @@ async fn process_genesis_commit( repo_repo: &dyn RepoRepository, block_store: &PostgresBlockStore, row: BrokenGenesisCommit, -) -> Result<(Did, i64), (i64, &'static str)> { +) -> Result<(Did, SequenceNumber), (SequenceNumber, &'static str)> { let commit_cid_str = row.commit_cid.ok_or((row.seq, "missing commit_cid"))?; let commit_cid = Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, "invalid CID"))?; let block = block_store @@ -73,12 +73,12 @@ pub async fn backfill_genesis_commit_blocks( let (success, failed) = results.iter().fold((0, 0), |(s, f), r| match r { Ok((did, seq)) => { - info!(seq = seq, did = %did, "Fixed genesis commit blocks_cids"); + info!(seq = seq.as_i64(), did = %did, "Fixed genesis commit blocks_cids"); (s + 1, f) } Err((seq, reason)) => { warn!( - seq = seq, + seq = seq.as_i64(), reason = reason, "Failed to process genesis commit" ); @@ -314,7 +314,7 @@ async fn process_record_blobs( record.collection.as_str(), record.rkey.as_str(), ); - (record_uri, CidLink::new_unchecked(blob_ref.cid)) + (record_uri, unsafe { CidLink::new_unchecked(blob_ref.cid) }) }) .collect::>(), ) @@ -392,7 +392,7 @@ pub async fn start_scheduled_tasks( blob_repo: Arc, blob_store: Arc, sso_repo: Arc, - mut shutdown_rx: watch::Receiver, + shutdown: CancellationToken, ) { let check_interval = Duration::from_secs( std::env::var("SCHEDULED_DELETE_CHECK_INTERVAL_SECS") @@ -411,11 +411,9 @@ pub async fn start_scheduled_tasks( loop { tokio::select! { - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - info!("Scheduled tasks service shutting down"); - break; - } + _ = shutdown.cancelled() => { + info!("Scheduled tasks service shutting down"); + break; } _ = ticker.tick() => { if let Err(e) = process_scheduled_deletions( @@ -538,7 +536,7 @@ pub async fn start_backup_tasks( backup_repo: Arc, block_store: PostgresBlockStore, backup_storage: Arc, - mut shutdown_rx: watch::Receiver, + shutdown: CancellationToken, ) { let backup_interval = Duration::from_secs(backup_interval_secs()); @@ -553,11 +551,9 @@ pub async fn start_backup_tasks( loop { tokio::select! { - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - info!("Backup service shutting down"); - break; - } + _ = shutdown.cancelled() => { + info!("Backup service shutting down"); + break; } _ = ticker.tick() => { if let Err(e) = process_scheduled_backups( diff --git a/crates/tranquil-pds/src/sso/config.rs b/crates/tranquil-pds/src/sso/config.rs index 6118605..5c313e6 100644 --- a/crates/tranquil-pds/src/sso/config.rs +++ b/crates/tranquil-pds/src/sso/config.rs @@ -1,3 +1,4 @@ +use crate::util::pds_hostname; use std::sync::OnceLock; use tranquil_db_traits::SsoProviderType; @@ -50,7 +51,7 @@ impl SsoConfig { }; if config.is_any_enabled() { - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_default(); + let hostname = pds_hostname(); if hostname.is_empty() || hostname == "localhost" { panic!( "PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \ diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 38ec1ea..5a0ab10 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -6,15 +6,19 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde::{Deserialize, Serialize}; -use tranquil_db_traits::SsoProviderType; +use tranquil_db_traits::{SsoAction, SsoProviderType}; use tranquil_types::RequestId; use super::config::SsoConfig; use crate::api::error::ApiError; use crate::auth::extractor::extract_bearer_token_from_header; use crate::auth::{generate_app_password, validate_bearer_token_cached}; -use crate::rate_limit::extract_client_ip; -use crate::state::{AppState, RateLimitKind}; +use crate::rate_limit::{ + AccountCreationLimit, RateLimited, SsoCallbackLimit, SsoInitiateLimit, SsoUnlinkLimit, + check_user_rate_limit_with_message, +}; +use crate::state::AppState; +use crate::util::{pds_hostname, pds_hostname_without_port}; fn generate_state() -> String { use rand::RngCore; @@ -71,18 +75,10 @@ pub struct SsoInitiateResponse { pub async fn sso_initiate( State(state): State, + _rate_limit: RateLimited, headers: HeaderMap, Json(input): Json, ) -> Result, ApiError> { - let client_ip = extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::SsoInitiate, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "SSO initiate rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - if input.provider.len() > 20 { return Err(ApiError::SsoProviderNotFound); } @@ -105,19 +101,21 @@ pub async fn sso_initiate( .get_provider(provider_type) .ok_or(ApiError::SsoProviderNotEnabled)?; - let action = input.action.as_deref().unwrap_or("login"); - if !["login", "link", "register"].contains(&action) { - return Err(ApiError::SsoInvalidAction); - } + let action = input + .action + .as_deref() + .map(SsoAction::parse) + .unwrap_or(Some(SsoAction::Login)) + .ok_or(ApiError::SsoInvalidAction)?; - let is_standalone = action == "register" && input.request_uri.is_none(); + let is_standalone = action == SsoAction::Register && input.request_uri.is_none(); let request_uri = input .request_uri .clone() .unwrap_or_else(|| "standalone".to_string()); let auth_did = match action { - "link" => { + SsoAction::Link => { let auth_header = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()); @@ -132,7 +130,7 @@ pub async fn sso_initiate( .map_err(|_| ApiError::SsoNotAuthenticated)?; Some(auth_user.did) } - "register" if is_standalone => None, + SsoAction::Register if is_standalone => None, _ => { let request_id = RequestId::new(request_uri.clone()); let _request_data = state @@ -217,9 +215,13 @@ fn redirect_to_login_with_error(request_uri: &str, message: &str) -> Response { pub async fn sso_callback( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Query(query): Query, ) -> Response { + sso_callback_internal(&state, query).await +} + +async fn sso_callback_internal(state: &AppState, query: SsoCallbackQuery) -> Response { tracing::debug!( has_code = query.code.is_some(), has_state = query.state.is_some(), @@ -227,15 +229,6 @@ pub async fn sso_callback( "SSO callback received" ); - let client_ip = extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "SSO callback rate limit exceeded"); - return redirect_to_error("Too many requests. Please try again later."); - } - if let Some(ref error) = query.error { tracing::warn!( error = %error, @@ -326,39 +319,38 @@ pub async fn sso_callback( } }; - match auth_state.action.as_str() { - "login" => { + match auth_state.action { + SsoAction::Login => { handle_sso_login( - &state, + state, &auth_state.request_uri, auth_state.provider, &user_info, ) .await } - "link" => { + SsoAction::Link => { let did = match auth_state.did { Some(d) => d, None => return redirect_to_error("Not authenticated"), }; - handle_sso_link(&state, did, auth_state.provider, &user_info).await + handle_sso_link(state, did, auth_state.provider, &user_info).await } - "register" => { + SsoAction::Register => { handle_sso_register( - &state, + state, &auth_state.request_uri, auth_state.provider, &user_info, ) .await } - _ => redirect_to_error("Unknown SSO action"), } } pub async fn sso_callback_post( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Form(form): Form, ) -> Response { tracing::debug!( @@ -376,7 +368,7 @@ pub async fn sso_callback_post( error_description: form.error_description, }; - sso_callback(State(state), headers, Query(query)).await + sso_callback_internal(&state, query).await } fn generate_registration_token() -> String { @@ -429,12 +421,7 @@ async fn handle_sso_login( }; let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await { - Ok(Some(info)) => { - info.email_verified - || info.discord_verified - || info.telegram_verified - || info.signal_verified - } + Ok(Some(info)) => info.channel_verification.has_any_verified(), Ok(None) => { tracing::error!("User not found for SSO login: {}", identity.did); return redirect_to_error("Account not found"); @@ -486,10 +473,10 @@ async fn handle_sso_login( "SSO login successful" ); - let has_totp = match state.user_repo.get_totp_record(&identity.did).await { - Ok(Some(record)) => record.verified, - _ => false, - }; + let has_totp = matches!( + state.user_repo.get_totp_record_state(&identity.did).await, + Ok(Some(tranquil_db_traits::TotpRecordState::Verified(_))) + ); if has_totp { return Redirect::to(&format!( @@ -657,8 +644,8 @@ pub async fn get_linked_accounts( id: id.id.to_string(), provider: id.provider.as_str().to_string(), provider_name: id.provider.display_name().to_string(), - provider_username: id.provider_username, - provider_email: id.provider_email, + provider_username: id.provider_username.map(|u| u.into_inner()), + provider_email: id.provider_email.map(|e| e.into_inner()), created_at: id.created_at.to_rfc3339(), last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), }) @@ -682,13 +669,12 @@ pub async fn unlink_account( auth: crate::auth::Auth, Json(input): Json, ) -> Result, ApiError> { - if !state - .check_rate_limit(RateLimitKind::SsoUnlink, auth.did.as_str()) - .await - { - tracing::warn!(did = %auth.did, "SSO unlink rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } + let _rate_limit = check_user_rate_limit_with_message::( + &state, + auth.did.as_str(), + "Too many unlink attempts. Please try again later.", + ) + .await?; let id = uuid::Uuid::parse_str(&input.id).map_err(|_| ApiError::InvalidId)?; @@ -746,18 +732,9 @@ pub struct PendingRegistrationResponse { pub async fn get_pending_registration( State(state): State, - headers: HeaderMap, + _rate_limit: RateLimited, Query(query): Query, ) -> Result, ApiError> { - let client_ip = extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "SSO pending registration rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - if query.token.len() > 100 { return Err(ApiError::InvalidRequest("Invalid token".into())); } @@ -771,9 +748,9 @@ pub async fn get_pending_registration( Ok(Json(PendingRegistrationResponse { request_uri: pending.request_uri, provider: pending.provider.as_str().to_string(), - provider_user_id: pending.provider_user_id, - provider_username: pending.provider_username, - provider_email: pending.provider_email, + provider_user_id: pending.provider_user_id.into_inner(), + provider_username: pending.provider_username.map(|u| u.into_inner()), + provider_email: pending.provider_email.map(|e| e.into_inner()), provider_email_verified: pending.provider_email_verified, })) } @@ -810,10 +787,9 @@ pub async fn check_handle_available( } }; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname_for_handles = pds_hostname_without_port(); let full_handle = format!("{}.{}", validated, hostname_for_handles); - let handle_typed = crate::types::Handle::new_unchecked(&full_handle); + let handle_typed = unsafe { crate::types::Handle::new_unchecked(&full_handle) }; let db_available = state .user_repo @@ -866,9 +842,10 @@ pub struct CompleteRegistrationResponse { pub async fn complete_registration( State(state): State, - headers: HeaderMap, + rate_limit: RateLimited, Json(input): Json, ) -> Result, ApiError> { + let client_ip = rate_limit.client_ip(); use jacquard_common::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::{mst::Mst, storage::BlockStore}; use k256::ecdsa::SigningKey; @@ -876,15 +853,6 @@ pub async fn complete_registration( use serde_json::json; use std::sync::Arc; - let client_ip = extract_client_ip(&headers, None); - if !state - .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) - .await - { - tracing::warn!(ip = %client_ip, "SSO registration rate limit exceeded"); - return Err(ApiError::RateLimitExceeded(None)); - } - if input.token.len() > 100 { return Err(ApiError::InvalidRequest("Invalid token".into())); } @@ -899,8 +867,8 @@ pub async fn complete_registration( .await? .ok_or(ApiError::SsoSessionExpired)?; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let hostname = pds_hostname(); + let hostname_for_handles = pds_hostname_without_port(); let handle = match crate::api::validation::validate_short_handle(&input.handle) { Ok(h) => format!("{}.{}", h, hostname_for_handles), @@ -913,7 +881,12 @@ pub async fn complete_registration( let email = input .email .clone() - .or_else(|| pending_preview.provider_email.clone()) + .or_else(|| { + pending_preview + .provider_email + .clone() + .map(|e| e.into_inner()) + }) .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); match email { @@ -939,7 +912,12 @@ pub async fn complete_registration( let email = input .email .clone() - .or_else(|| pending_preview.provider_email.clone()) + .or_else(|| { + pending_preview + .provider_email + .clone() + .map(|e| e.into_inner()) + }) .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); @@ -956,14 +934,10 @@ pub async fn complete_registration( None => None, }; - if let Some(ref code) = input.invite_code { - let valid = state - .infra_repo - .is_invite_code_valid(code) - .await - .unwrap_or(false); - if !valid { - return Err(ApiError::InvalidInviteCode); + let _validated_invite_code = if let Some(ref code) = input.invite_code { + match state.infra_repo.validate_invite_code(code).await { + Ok(validated) => Some(validated), + Err(_) => return Err(ApiError::InvalidInviteCode), } } else { let invite_required = std::env::var("INVITE_CODE_REQUIRED") @@ -972,12 +946,13 @@ pub async fn complete_registration( if invite_required { return Err(ApiError::InviteCodeRequired); } - } + None + }; - let handle_typed = crate::types::Handle::new_unchecked(&handle); + let handle_typed = unsafe { crate::types::Handle::new_unchecked(&handle) }; let reserved = state .user_repo - .reserve_handle(&handle_typed, &client_ip) + .reserve_handle(&handle_typed, client_ip) .await .unwrap_or(false); @@ -1076,7 +1051,7 @@ pub async fn complete_registration( }; let rev = Tid::now(LimitedU32::MIN); - let did_typed = crate::types::Did::new_unchecked(&did); + let did_typed = unsafe { crate::types::Did::new_unchecked(&did) }; let (commit_bytes, _sig) = match crate::api::repo::record::utils::create_signed_commit( &did_typed, mst_root, @@ -1144,9 +1119,15 @@ pub async fn complete_registration( invite_code: input.invite_code.clone(), birthdate_pref, sso_provider: pending_preview.provider, - sso_provider_user_id: pending_preview.provider_user_id.clone(), - sso_provider_username: pending_preview.provider_username.clone(), - sso_provider_email: pending_preview.provider_email.clone(), + sso_provider_user_id: pending_preview.provider_user_id.clone().into_inner(), + sso_provider_username: pending_preview + .provider_username + .clone() + .map(|u| u.into_inner()), + sso_provider_email: pending_preview + .provider_email + .clone() + .map(|e| e.into_inner()), sso_provider_email_verified: pending_preview.provider_email_verified, pending_registration_token: input.token.clone(), }; @@ -1179,8 +1160,12 @@ pub async fn complete_registration( { tracing::warn!("Failed to sequence identity event for {}: {}", did, e); } - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did_typed, + tranquil_db_traits::AccountStatus::Active, + ) + .await { tracing::warn!("Failed to sequence account event for {}: {}", did, e); } @@ -1189,8 +1174,8 @@ pub async fn complete_registration( "$type": "app.bsky.actor.profile", "displayName": handle_typed.as_str() }); - let profile_collection = crate::types::Nsid::new_unchecked("app.bsky.actor.profile"); - let profile_rkey = crate::types::Rkey::new_unchecked("self"); + let profile_collection = unsafe { crate::types::Nsid::new_unchecked("app.bsky.actor.profile") }; + let profile_rkey = unsafe { crate::types::Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did_typed, @@ -1217,7 +1202,7 @@ pub async fn complete_registration( user_id: create_result.user_id, name: app_password_name.clone(), password_hash: app_password_hash, - privileged: false, + privilege: tranquil_db_traits::AppPasswordPrivilege::Standard, scopes: None, created_by_controller_did: None, }; @@ -1260,7 +1245,7 @@ pub async fn complete_registration( let channel_auto_verified = verification_channel == "email" && pending_preview.provider_email_verified - && pending_preview.provider_email.as_ref() == email.as_ref(); + && pending_preview.provider_email.as_ref().map(|e| e.as_str()) == email.as_deref(); if channel_auto_verified { let _ = state @@ -1304,7 +1289,7 @@ pub async fn complete_registration( refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - legacy_login: false, + login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, scope: None, controller_did: None, @@ -1315,13 +1300,12 @@ pub async fn complete_registration( return Err(ApiError::InternalError(None)); } - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_welcome( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id.unwrap_or(uuid::Uuid::nil()), - &hostname, + hostname, ) .await { @@ -1367,7 +1351,7 @@ pub async fn complete_registration( verification_channel, &verification_recipient, &formatted_token, - &hostname, + hostname, ) .await { diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index b481382..36fc5b9 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -1,4 +1,5 @@ use crate::appview::DidResolver; +use crate::auth::webauthn::WebAuthnConfig; use crate::cache::{Cache, DistributedRateLimiter, create_cache}; use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; @@ -7,10 +8,12 @@ use crate::repo::PostgresBlockStore; use crate::sso::{SsoConfig, SsoManager}; use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; use crate::sync::firehose::SequencedEvent; +use crate::util::pds_hostname; use sqlx::PgPool; use std::error::Error; use std::sync::Arc; use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; use tranquil_db::{ BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, OAuthRepository, PostgresRepositories, RepoEventNotifier, RepoRepository, SessionRepository, @@ -41,8 +44,11 @@ pub struct AppState { pub did_resolver: Arc, pub sso_repo: Arc, pub sso_manager: SsoManager, + pub webauthn_config: Arc, + pub shutdown: CancellationToken, } +#[derive(Debug, Clone, Copy)] pub enum RateLimitKind { Login, AccountCreation, @@ -116,7 +122,7 @@ impl RateLimitKind { } impl AppState { - pub async fn new() -> Result> { + pub async fn new(shutdown: CancellationToken) -> Result> { let database_url = std::env::var("DATABASE_URL") .map_err(|_| "DATABASE_URL environment variable must be set")?; @@ -157,10 +163,10 @@ impl AppState { .await .map_err(|e| format!("Failed to run migrations: {}", e))?; - Ok(Self::from_db(db).await) + Ok(Self::from_db(db, shutdown).await) } - pub async fn from_db(db: PgPool) -> Self { + pub async fn from_db(db: PgPool, shutdown: CancellationToken) -> Self { AuthConfig::init(); let repos = Arc::new(PostgresRepositories::new(db.clone())); @@ -180,6 +186,10 @@ impl AppState { let did_resolver = Arc::new(DidResolver::new()); let sso_config = SsoConfig::init(); let sso_manager = SsoManager::from_config(sso_config); + let webauthn_config = Arc::new( + WebAuthnConfig::new(pds_hostname()) + .expect("Failed to create WebAuthn config at startup"), + ); Self { user_repo: repos.user.clone(), @@ -204,6 +214,8 @@ impl AppState { distributed_rate_limiter, did_resolver, sso_manager, + webauthn_config, + shutdown, } } diff --git a/crates/tranquil-pds/src/sync/commit.rs b/crates/tranquil-pds/src/sync/commit.rs index 0559a88..07332ac 100644 --- a/crates/tranquil-pds/src/sync/commit.rs +++ b/crates/tranquil-pds/src/sync/commit.rs @@ -1,6 +1,6 @@ use crate::api::error::ApiError; use crate::state::AppState; -use crate::sync::util::{AccountStatus, assert_repo_availability, get_account_with_status}; +use crate::sync::util::{assert_repo_availability, get_account_with_status}; use axum::{ Json, extract::{Query, State}, @@ -13,6 +13,7 @@ use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; use std::str::FromStr; use tracing::error; +use tranquil_db_traits::AccountStatus; use tranquil_types::Did; async fn get_rev_from_commit(state: &AppState, cid_str: &str) -> Option { @@ -130,7 +131,7 @@ pub async fn list_repos( head: cid_str, rev, active: status.is_active(), - status: status.as_str().map(String::from), + status: status.for_firehose().map(String::from), }); } let next_cursor = if has_more { @@ -212,7 +213,7 @@ pub async fn get_repo_status( Json(GetRepoStatusOutput { did: account.did, active: account.status.is_active(), - status: account.status.as_str().map(String::from), + status: account.status.for_firehose().map(String::from), rev, }), ) diff --git a/crates/tranquil-pds/src/sync/deprecated.rs b/crates/tranquil-pds/src/sync/deprecated.rs index f254415..e9aa390 100644 --- a/crates/tranquil-pds/src/sync/deprecated.rs +++ b/crates/tranquil-pds/src/sync/deprecated.rs @@ -19,13 +19,14 @@ use tranquil_types::Did; const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; async fn check_admin_or_self(state: &AppState, headers: &HeaderMap, did: &str) -> bool { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { + let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( + headers, + "Authorization", + )) { Some(t) => t, None => return false, }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let dpop_proof = crate::util::get_header_str(headers, "DPoP"); let http_uri = "/"; match crate::auth::validate_token_with_dpop( state.user_repo.as_ref(), diff --git a/crates/tranquil-pds/src/sync/frame.rs b/crates/tranquil-pds/src/sync/frame.rs index 0c4cc2e..36e2346 100644 --- a/crates/tranquil-pds/src/sync/frame.rs +++ b/crates/tranquil-pds/src/sync/frame.rs @@ -2,6 +2,7 @@ use crate::sync::firehose::SequencedEvent; use cid::Cid; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use tranquil_scopes::RepoAction; #[derive(Debug, Serialize, Deserialize)] pub struct FrameHeader { @@ -38,7 +39,7 @@ struct JsonRepoOp { #[derive(Debug, Serialize, Deserialize)] pub struct RepoOp { - pub action: String, + pub action: RepoAction, pub path: String, pub cid: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -159,11 +160,13 @@ impl CommitFrameBuilder { serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); let ops: Vec = json_ops .into_iter() - .map(|op| RepoOp { - action: op.action, - path: op.path, - cid: op.cid.and_then(|s| Cid::from_str(&s).ok()), - prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), + .filter_map(|op| { + Some(RepoOp { + action: RepoAction::parse_str(&op.action)?, + path: op.path, + cid: op.cid.and_then(|s| Cid::from_str(&s).ok()), + prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), + }) }) .collect(); let rev = self.rev.unwrap_or_else(placeholder_rev); @@ -202,7 +205,7 @@ impl TryFrom for CommitFrame { CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) })?; let builder = CommitFrameBuilder::new( - event.seq, + event.seq.as_i64(), event.did.to_string(), commit_cid.as_str(), event.prev_cid.as_ref().map(|c| c.as_str()), diff --git a/crates/tranquil-pds/src/sync/listener.rs b/crates/tranquil-pds/src/sync/listener.rs index ebf1c9e..bba0bea 100644 --- a/crates/tranquil-pds/src/sync/listener.rs +++ b/crates/tranquil-pds/src/sync/listener.rs @@ -2,13 +2,21 @@ use crate::state::AppState; use crate::sync::firehose::SequencedEvent; use std::sync::atomic::{AtomicI64, Ordering}; use tracing::{debug, error, info, warn}; +use tranquil_db_traits::SequenceNumber; static LAST_BROADCAST_SEQ: AtomicI64 = AtomicI64::new(0); pub async fn start_sequencer_listener(state: AppState) { - let initial_seq = state.repo_repo.get_max_seq().await.unwrap_or(0); - LAST_BROADCAST_SEQ.store(initial_seq, Ordering::SeqCst); - info!(initial_seq = initial_seq, "Initialized sequencer listener"); + let initial_seq = state + .repo_repo + .get_max_seq() + .await + .unwrap_or(SequenceNumber::ZERO); + LAST_BROADCAST_SEQ.store(initial_seq.as_i64(), Ordering::SeqCst); + info!( + initial_seq = initial_seq.as_i64(), + "Initialized sequencer listener" + ); tokio::spawn(async move { info!("Starting sequencer listener background task"); loop { @@ -27,7 +35,7 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("Failed to subscribe to events: {:?}", e))?; info!("Connected to database and listening for repo updates"); - let catchup_start = LAST_BROADCAST_SEQ.load(Ordering::SeqCst); + let catchup_start = SequenceNumber::from_raw(LAST_BROADCAST_SEQ.load(Ordering::SeqCst)); let events = state .repo_repo .get_events_since_seq(catchup_start, None) @@ -36,14 +44,14 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { if !events.is_empty() { info!( count = events.len(), - from_seq = catchup_start, + from_seq = catchup_start.as_i64(), "Broadcasting catch-up events" ); events.into_iter().for_each(|event| { let seq = event.seq; let firehose_event = to_firehose_event(event); let _ = state.firehose_tx.send(firehose_event); - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); }); } loop { @@ -63,7 +71,10 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { if seq_id > last_seq + 1 { let gap_events = state .repo_repo - .get_events_in_seq_range(last_seq, seq_id) + .get_events_in_seq_range( + SequenceNumber::from_raw(last_seq), + SequenceNumber::from_raw(seq_id), + ) .await .unwrap_or_default(); if !gap_events.is_empty() { @@ -72,13 +83,13 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { let seq = event.seq; let firehose_event = to_firehose_event(event); let _ = state.firehose_tx.send(firehose_event); - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); }); } } let event = state .repo_repo - .get_event_by_seq(seq_id) + .get_event_by_seq(SequenceNumber::from_raw(seq_id)) .await .ok() .flatten(); @@ -97,7 +108,7 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> { warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); } } - LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst); + LAST_BROADCAST_SEQ.store(seq.as_i64(), Ordering::SeqCst); } else { warn!( seq = seq_id, diff --git a/crates/tranquil-pds/src/sync/mod.rs b/crates/tranquil-pds/src/sync/mod.rs index a97860b..ae964b5 100644 --- a/crates/tranquil-pds/src/sync/mod.rs +++ b/crates/tranquil-pds/src/sync/mod.rs @@ -18,8 +18,8 @@ pub use crawl::{notify_of_update, request_crawl}; pub use deprecated::{get_checkout, get_head}; pub use repo::{get_blocks, get_record, get_repo}; pub use subscribe_repos::subscribe_repos; +pub use tranquil_db_traits::AccountStatus; pub use util::{ - AccountStatus, RepoAccount, RepoAvailabilityError, assert_repo_availability, - get_account_with_status, + RepoAccount, RepoAvailabilityError, assert_repo_availability, get_account_with_status, }; pub use verify::{CarVerifier, VerifiedCar, VerifyError}; diff --git a/crates/tranquil-pds/src/sync/subscribe_repos.rs b/crates/tranquil-pds/src/sync/subscribe_repos.rs index bd091e6..317891a 100644 --- a/crates/tranquil-pds/src/sync/subscribe_repos.rs +++ b/crates/tranquil-pds/src/sync/subscribe_repos.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::broadcast::error::RecvError; use tracing::{error, info, warn}; +use tranquil_db_traits::SequenceNumber; const BACKFILL_BATCH_SIZE: i64 = 1000; @@ -69,12 +70,17 @@ async fn handle_socket_inner( params: SubscribeReposParams, ) -> Result<(), ()> { let mut rx = state.firehose_tx.subscribe(); - let mut last_seen: i64 = -1; + let mut last_seen = SequenceNumber::UNSET; if let Some(cursor) = params.cursor { - let current_seq = state.repo_repo.get_max_seq().await.unwrap_or(0); + let cursor_seq = SequenceNumber::from_raw(cursor); + let current_seq = state + .repo_repo + .get_max_seq() + .await + .unwrap_or(SequenceNumber::ZERO); - if cursor > current_seq { + if cursor_seq > current_seq { if let Ok(error_bytes) = format_error_frame("FutureCursor", Some("Cursor in the future.")) { @@ -88,12 +94,12 @@ async fn handle_socket_inner( let first_event = state .repo_repo - .get_events_since_cursor(cursor, 1) + .get_events_since_cursor(cursor_seq, 1) .await .ok() .and_then(|events| events.into_iter().next()); - let mut current_cursor = cursor; + let mut current_cursor = cursor_seq; if let Some(ref event) = first_event && event.created_at < backfill_time @@ -113,7 +119,7 @@ async fn handle_socket_inner( .flatten(); if let Some(earliest_seq) = earliest { - current_cursor = earliest_seq - 1; + current_cursor = SequenceNumber::from_raw(earliest_seq.as_i64() - 1); } } diff --git a/crates/tranquil-pds/src/sync/util.rs b/crates/tranquil-pds/src/sync/util.rs index f0c864f..5c49f58 100644 --- a/crates/tranquil-pds/src/sync/util.rs +++ b/crates/tranquil-pds/src/sync/util.rs @@ -11,99 +11,13 @@ use cid::Cid; use iroh_car::{CarHeader, CarWriter}; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; -use serde::Serialize; use std::collections::{BTreeMap, HashMap}; use std::io::Cursor; use std::str::FromStr; use tokio::io::AsyncWriteExt; -use tranquil_db_traits::RepoRepository; +use tranquil_db_traits::{AccountStatus, RepoEventType, RepoRepository}; use tranquil_types::Did; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum AccountStatus { - Active, - Takendown, - Suspended, - Deactivated, - Deleted, -} - -impl AccountStatus { - pub fn as_str(&self) -> Option<&'static str> { - match self { - Self::Active => None, - Self::Takendown => Some("takendown"), - Self::Suspended => Some("suspended"), - Self::Deactivated => Some("deactivated"), - Self::Deleted => Some("deleted"), - } - } - - pub fn is_active(&self) -> bool { - matches!(self, Self::Active) - } - - pub fn is_takendown(&self) -> bool { - matches!(self, Self::Takendown) - } - - pub fn is_suspended(&self) -> bool { - matches!(self, Self::Suspended) - } - - pub fn is_deactivated(&self) -> bool { - matches!(self, Self::Deactivated) - } - - pub fn is_deleted(&self) -> bool { - matches!(self, Self::Deleted) - } - - pub fn allows_read(&self) -> bool { - matches!(self, Self::Active | Self::Deactivated) - } - - pub fn allows_write(&self) -> bool { - matches!(self, Self::Active) - } - - pub fn from_db_fields( - takedown_ref: Option<&str>, - deactivated_at: Option>, - ) -> Self { - if takedown_ref.is_some() { - Self::Takendown - } else if deactivated_at.is_some() { - Self::Deactivated - } else { - Self::Active - } - } -} - -impl From for AccountStatus { - fn from(state: crate::types::AccountState) -> Self { - match state { - crate::types::AccountState::Active => AccountStatus::Active, - crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, - crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, - crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, - } - } -} - -impl From<&crate::types::AccountState> for AccountStatus { - fn from(state: &crate::types::AccountState) -> Self { - match state { - crate::types::AccountState::Active => AccountStatus::Active, - crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, - crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, - crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, - } - } -} - pub struct RepoAccount { pub did: String, pub user_id: uuid::Uuid, @@ -233,7 +147,7 @@ fn format_identity_event(event: &SequencedEvent) -> Result, anyhow::Erro let frame = IdentityFrame { did: event.did.to_string(), handle: event.handle.as_ref().map(|h| h.to_string()), - seq: event.seq, + seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { @@ -250,8 +164,10 @@ fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error let frame = AccountFrame { did: event.did.to_string(), active: event.active.unwrap_or(true), - status: event.status.clone(), - seq: event.seq, + status: event + .status + .and_then(|s| s.for_firehose().map(String::from)), + seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { @@ -298,7 +214,7 @@ async fn format_sync_event( did: event.did.to_string(), rev, blocks: car_bytes, - seq: event.seq, + seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { @@ -315,11 +231,11 @@ pub async fn format_event_for_sending( state: &AppState, event: SequencedEvent, ) -> Result, anyhow::Error> { - match event.event_type.as_str() { - "identity" => return format_identity_event(&event), - "account" => return format_account_event(&event), - "sync" => return format_sync_event(state, &event).await, - _ => {} + match event.event_type { + RepoEventType::Identity => return format_identity_event(&event), + RepoEventType::Account => return format_account_event(&event), + RepoEventType::Sync => return format_sync_event(state, &event).await, + RepoEventType::Commit => {} } let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_link = event.prev_cid.clone(); @@ -440,7 +356,7 @@ fn format_sync_event_with_prefetched( did: event.did.to_string(), rev, blocks: car_bytes, - seq: event.seq, + seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { @@ -457,11 +373,11 @@ pub async fn format_event_with_prefetched_blocks( event: SequencedEvent, prefetched: &HashMap, ) -> Result, anyhow::Error> { - match event.event_type.as_str() { - "identity" => return format_identity_event(&event), - "account" => return format_account_event(&event), - "sync" => return format_sync_event_with_prefetched(&event, prefetched), - _ => {} + match event.event_type { + RepoEventType::Identity => return format_identity_event(&event), + RepoEventType::Account => return format_account_event(&event), + RepoEventType::Sync => return format_sync_event_with_prefetched(&event, prefetched), + RepoEventType::Commit => {} } let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_link = event.prev_cid.clone(); diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index 6991979..058bfa0 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -4,6 +4,7 @@ use ipld_core::ipld::Ipld; use rand::Rng; use serde_json::Value as JsonValue; use std::collections::BTreeMap; +use std::net::SocketAddr; use std::str::FromStr; use std::sync::OnceLock; @@ -11,6 +12,8 @@ const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; static MAX_BLOB_SIZE: OnceLock = OnceLock::new(); +static PDS_HOSTNAME: OnceLock = OnceLock::new(); +static PDS_HOSTNAME_WITHOUT_PORT: OnceLock = OnceLock::new(); pub fn get_max_blob_size() -> usize { *MAX_BLOB_SIZE.get_or_init(|| { @@ -69,7 +72,11 @@ pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec .unwrap_or_default() } -pub fn extract_client_ip(headers: &HeaderMap) -> String { +pub fn get_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|h| h.to_str().ok()) +} + +pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> String { if let Some(forwarded) = headers.get("x-forwarded-for") && let Ok(value) = forwarded.to_str() && let Some(first_ip) = value.split(',').next() @@ -81,11 +88,20 @@ pub fn extract_client_ip(headers: &HeaderMap) -> String { { return value.trim().to_string(); } - "unknown".to_string() + addr.map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) } -pub fn pds_hostname() -> String { - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) +pub fn pds_hostname() -> &'static str { + PDS_HOSTNAME + .get_or_init(|| std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())) +} + +pub fn pds_hostname_without_port() -> &'static str { + PDS_HOSTNAME_WITHOUT_PORT.get_or_init(|| { + let hostname = pds_hostname(); + hostname.split(':').next().unwrap_or(hostname).to_string() + }) } pub fn pds_public_url() -> String { diff --git a/crates/tranquil-pds/tests/commit_signing.rs b/crates/tranquil-pds/tests/commit_signing.rs index 5443cce..4ae8650 100644 --- a/crates/tranquil-pds/tests/commit_signing.rs +++ b/crates/tranquil-pds/tests/commit_signing.rs @@ -99,7 +99,7 @@ fn test_create_signed_commit_helper() { use tranquil_pds::api::repo::record::utils::create_signed_commit; let signing_key = SigningKey::random(&mut rand::thread_rng()); - let did = Did::new_unchecked("did:plc:testuser123456789abcdef"); + let did = unsafe { Did::new_unchecked("did:plc:testuser123456789abcdef") }; let data_cid = Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); let rev = Tid::now(LimitedU32::MIN).to_string(); diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 3be4c54..36397d1 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, OnceLock, RwLock}; #[allow(unused_imports)] use std::time::Duration; use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; use tranquil_pds::state::AppState; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; @@ -546,7 +547,7 @@ async fn spawn_app(database_url: String) -> String { .with_email_update_limit(10000) .with_oauth_authorize_limit(10000) .with_oauth_token_limit(10000); - let state = AppState::from_db(pool) + let state = AppState::from_db(pool, CancellationToken::new()) .await .with_rate_limiters(rate_limiters); tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await; diff --git a/crates/tranquil-pds/tests/firehose_validation.rs b/crates/tranquil-pds/tests/firehose_validation.rs index b8e059a..c582531 100644 --- a/crates/tranquil-pds/tests/firehose_validation.rs +++ b/crates/tranquil-pds/tests/firehose_validation.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::io::Cursor; use tokio_tungstenite::{connect_async, tungstenite}; +use tranquil_scopes::RepoAction; #[derive(Debug, Deserialize, Serialize)] struct FrameHeader { @@ -39,7 +40,7 @@ struct CommitFrame { #[derive(Debug, Deserialize)] struct RepoOp { - action: String, + action: RepoAction, path: String, cid: Option, prev: Option, @@ -292,7 +293,7 @@ async fn test_firehose_frame_structure() { println!("\nOps validation:"); for (i, op) in frame.ops.iter().enumerate() { println!(" Op {}:", i); - println!(" action: {}", op.action); + println!(" action: {:?}", op.action); println!(" path: {}", op.path); println!(" cid: {:?}", op.cid); println!( @@ -300,18 +301,13 @@ async fn test_firehose_frame_structure() { op.prev ); - assert!( - ["create", "update", "delete"].contains(&op.action.as_str()), - "Invalid action: {}", - op.action - ); assert!( op.path.contains('/'), "Path should contain collection/rkey: {}", op.path ); - if op.action == "create" { + if op.action == RepoAction::Create { assert!(op.cid.is_some(), "Create op should have cid"); } } @@ -445,11 +441,11 @@ async fn test_firehose_update_has_prev_field() { for op in &frame.ops { println!( - "Op: action={}, path={}, cid={:?}, prev={:?}", + "Op: action={:?}, path={}, cid={:?}, prev={:?}", op.action, op.path, op.cid, op.prev ); - if op.action == "update" && op.path.contains("app.bsky.actor.profile") { + if op.action == RepoAction::Update && op.path.contains("app.bsky.actor.profile") { assert!( op.prev.is_some(), "Update operation should have 'prev' field with old CID! Got: {:?}", diff --git a/crates/tranquil-pds/tests/shutdown_unit.rs b/crates/tranquil-pds/tests/shutdown_unit.rs new file mode 100644 index 0000000..4067943 --- /dev/null +++ b/crates/tranquil-pds/tests/shutdown_unit.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio_util::sync::CancellationToken; + +#[test] +fn test_panic_hook_cancels_shutdown_token() { + let shutdown = CancellationToken::new(); + let shutdown_clone = shutdown.clone(); + + let panic_occurred = Arc::new(AtomicBool::new(false)); + let panic_occurred_clone = panic_occurred.clone(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + panic_occurred_clone.store(true, Ordering::SeqCst); + shutdown_clone.cancel(); + default_hook(info); + })); + + panic!("simulated corrupted data panic"); + })); + + assert!(result.is_err()); + assert!(panic_occurred.load(Ordering::SeqCst)); + assert!(shutdown.is_cancelled()); + + let _ = std::panic::take_hook(); +} + +#[test] +fn test_cancellation_token_propagates_to_clones() { + let shutdown = CancellationToken::new(); + let clone1 = shutdown.clone(); + let clone2 = shutdown.clone(); + + assert!(!shutdown.is_cancelled()); + assert!(!clone1.is_cancelled()); + assert!(!clone2.is_cancelled()); + + shutdown.cancel(); + + assert!(shutdown.is_cancelled()); + assert!(clone1.is_cancelled()); + assert!(clone2.is_cancelled()); +} + +#[tokio::test] +async fn test_cancelled_future_completes_on_cancel() { + let shutdown = CancellationToken::new(); + let shutdown_clone = shutdown.clone(); + + let handle = tokio::spawn(async move { + shutdown_clone.cancelled().await; + true + }); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + assert!(!handle.is_finished()); + + shutdown.cancel(); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + handle, + ) + .await; + + assert!(result.is_ok()); + assert!(result.unwrap().unwrap()); +} diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs index 1adeec8..d05346f 100644 --- a/crates/tranquil-pds/tests/sso.rs +++ b/crates/tranquil-pds/tests/sso.rs @@ -232,10 +232,12 @@ async fn test_external_identity_repository_crud() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did = Did::new_unchecked(format!( - "did:plc:test{}", - &uuid::Uuid::new_v4().simple().to_string()[..12] - )); + let did = unsafe { + Did::new_unchecked(format!( + "did:plc:test{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + )) + }; let provider = SsoProviderType::Github; let provider_user_id = format!("github_user_{}", uuid::Uuid::new_v4().simple()); @@ -350,14 +352,18 @@ async fn test_external_identity_unique_constraints() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did1 = Did::new_unchecked(format!( - "did:plc:uc1{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )); - let did2 = Did::new_unchecked(format!( - "did:plc:uc2{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )); + let did1 = unsafe { + Did::new_unchecked(format!( + "did:plc:uc1{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )) + }; + let did2 = unsafe { + Did::new_unchecked(format!( + "did:plc:uc2{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )) + }; let provider_user_id = format!("unique_test_{}", uuid::Uuid::new_v4().simple()); sqlx::query!( @@ -577,11 +583,13 @@ async fn test_delete_external_identity_wrong_did() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did = Did::new_unchecked(format!( - "did:plc:del{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )); - let wrong_did = Did::new_unchecked("did:plc:wrongdid12345"); + let did = unsafe { + Did::new_unchecked(format!( + "did:plc:del{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )) + }; + let wrong_did = unsafe { Did::new_unchecked("did:plc:wrongdid12345") }; sqlx::query!( "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", diff --git a/crates/tranquil-scopes/src/parser.rs b/crates/tranquil-scopes/src/parser.rs index e83d773..dbc65a9 100644 --- a/crates/tranquil-scopes/src/parser.rs +++ b/crates/tranquil-scopes/src/parser.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,7 +28,8 @@ pub struct RepoScope { pub actions: HashSet, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum RepoAction { Create, Update, diff --git a/crates/tranquil-types/src/lib.rs b/crates/tranquil-types/src/lib.rs index 5d2124f..a59bce8 100644 --- a/crates/tranquil-types/src/lib.rs +++ b/crates/tranquil-types/src/lib.rs @@ -166,7 +166,8 @@ macro_rules! validated_string_newtype { Ok(Self(s)) } - pub fn new_unchecked(s: impl Into) -> Self { + #[allow(unsafe_code, clippy::missing_safety_doc)] + pub unsafe fn new_unchecked(s: impl Into) -> Self { Self(s.into()) } } @@ -228,7 +229,8 @@ impl Handle { Ok(Self(s)) } - pub fn new_unchecked(s: impl Into) -> Self { + #[allow(unsafe_code, clippy::missing_safety_doc)] + pub unsafe fn new_unchecked(s: impl Into) -> Self { Self(s.into()) } } @@ -489,7 +491,8 @@ impl CidLink { Ok(Self(s)) } - pub fn new_unchecked(s: impl Into) -> Self { + #[allow(unsafe_code, clippy::missing_safety_doc)] + pub unsafe fn new_unchecked(s: impl Into) -> Self { Self(s.into()) }