From faea66191750e1cb993dbb56425fef091af7b4ee Mon Sep 17 00:00:00 2001 From: lewis Date: Sat, 20 Dec 2025 13:05:43 +0200 Subject: [PATCH] OAuth scopes full impl. --- ...d34ae9e846f3bb9f8693ecd6d90463e83d114.json | 17 + ...1027c873c8c2d31e695a14241220c1339937f.json | 29 + ...7b3f72f02571976d875d5c75542c69f0fcdfe.json | 22 + ...d1f8eea4fe719c6cba9406a9843bea2f8dc9e.json | 29 + ...babde5e48a5cabe08a5a2135e8856efd844d.json} | 12 +- ...69dcc683e796287e41d5180340296286fcbe.json} | 9 +- ...4c0dad7952676303749d140294c46b9536b91.json | 15 + ...7aa9f67b3fec5dac616edef36fbeb143d76f0.json | 15 + ...4dcc7f54b72983ba8ebd66fd805851db5c06c.json | 34 - ...42ede88cddc842bdf37f2ef082b252ab1642c.json | 16 + ...23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json | 22 + ...9e4d2cdd5d6cda0add6e5d56471cd319f92cd.json | 15 + ...cbc6aff9ab373946ff243512c52f857b7980d.json | 22 - Cargo.lock | 1 + Cargo.toml | 1 + TODO.md | 16 +- frontend/src/App.svelte | 15 + frontend/src/lib/router.svelte.ts | 9 +- frontend/src/routes/OAuth2FA.svelte | 213 ++++ frontend/src/routes/OAuthAccounts.svelte | 264 +++++ frontend/src/routes/OAuthConsent.svelte | 451 +++++++ frontend/src/routes/OAuthError.svelte | 81 ++ frontend/src/routes/OAuthLogin.svelte | 269 +++++ .../20251221_oauth_scope_preferences.sql | 12 + src/api/actor/preferences.rs | 62 +- src/api/admin/account/info.rs | 5 +- src/api/admin/account/search.rs | 40 +- src/api/admin/server_stats.rs | 22 +- src/api/identity/account.rs | 311 ++--- src/api/identity/did.rs | 62 +- src/api/identity/plc/request.rs | 16 +- src/api/identity/plc/sign.rs | 16 +- src/api/identity/plc/submit.rs | 129 +- src/api/notification_prefs.rs | 118 +- src/api/proxy.rs | 14 +- src/api/repo/blob.rs | 51 +- src/api/repo/import.rs | 35 +- src/api/repo/record/batch.rs | 108 +- src/api/repo/record/delete.rs | 43 +- src/api/repo/record/read.rs | 38 +- src/api/repo/record/utils.rs | 121 +- src/api/repo/record/write.rs | 135 ++- src/api/server/account_status.rs | 70 +- src/api/server/email.rs | 55 +- src/api/server/password.rs | 30 +- src/api/server/service_auth.rs | 72 +- src/api/server/session.rs | 85 +- src/api/temp.rs | 134 ++- src/api/verification.rs | 52 +- src/appview/mod.rs | 36 +- src/auth/extractor.rs | 128 +- src/auth/mod.rs | 107 +- src/auth/scope_check.rs | 118 ++ src/auth/service.rs | 11 +- src/auth/verify.rs | 30 +- src/comms/mod.rs | 4 +- src/comms/sender.rs | 3 +- src/comms/service.rs | 2 +- src/config.rs | 12 +- src/crawlers.rs | 9 +- src/handle/mod.rs | 2 +- src/lib.rs | 18 +- src/main.rs | 6 +- src/metrics.rs | 30 +- src/oauth/client.rs | 68 +- src/oauth/db/mod.rs | 10 +- src/oauth/db/request.rs | 61 + src/oauth/db/scope_preference.rs | 103 ++ src/oauth/db/token.rs | 15 + src/oauth/endpoints/authorize.rs | 1030 +++++++++++----- src/oauth/endpoints/metadata.rs | 11 + src/oauth/endpoints/par.rs | 102 +- src/oauth/endpoints/token/grants.rs | 57 +- src/oauth/endpoints/token/helpers.rs | 4 +- src/oauth/endpoints/token/mod.rs | 35 +- src/oauth/mod.rs | 4 +- src/oauth/scopes/definitions.rs | 134 +++ src/oauth/scopes/error.rs | 39 + src/oauth/scopes/mod.rs | 12 + src/oauth/scopes/parser.rs | 483 ++++++++ src/oauth/scopes/permissions.rs | 488 ++++++++ src/oauth/templates.rs | 595 ---------- src/oauth/types.rs | 1 + src/oauth/verify.rs | 19 +- src/plc/mod.rs | 11 +- src/rate_limit.rs | 14 +- src/sync/import.rs | 87 +- src/sync/util.rs | 49 +- src/sync/verify.rs | 13 +- src/validation/mod.rs | 91 +- tests/account_notifications.rs | 70 +- tests/admin_search.rs | 45 +- tests/admin_stats.rs | 2 +- tests/change_password.rs | 12 +- tests/common/mod.rs | 5 +- tests/email_update.rs | 22 +- tests/image_processing.rs | 119 +- tests/jwt_security.rs | 402 +++++-- tests/lifecycle_record.rs | 514 ++++++-- tests/lifecycle_social.rs | 2 +- tests/notifications.rs | 6 +- tests/oauth.rs | 1046 ++++++++++++++--- tests/oauth_client_metadata.rs | 93 +- tests/oauth_lifecycle.rs | 128 +- tests/oauth_scopes.rs | 753 ++++++++++++ tests/oauth_security.rs | 950 ++++++++++++--- tests/plc_operations.rs | 136 ++- tests/plc_validation.rs | 166 ++- tests/record_validation.rs | 235 +++- tests/security_fixes.rs | 107 +- tests/server.rs | 145 ++- tests/session_management.rs | 42 +- tests/sync_deprecated.rs | 144 ++- tests/verify_live_commit.rs | 4 +- 114 files changed, 9805 insertions(+), 2808 deletions(-) create mode 100644 .sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json create mode 100644 .sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json create mode 100644 .sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json create mode 100644 .sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json rename .sqlx/{query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json => query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json} (64%) rename .sqlx/{query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json => query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json} (57%) create mode 100644 .sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json create mode 100644 .sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json delete mode 100644 .sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json create mode 100644 .sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json create mode 100644 .sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json create mode 100644 .sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json delete mode 100644 .sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json create mode 100644 frontend/src/routes/OAuth2FA.svelte create mode 100644 frontend/src/routes/OAuthAccounts.svelte create mode 100644 frontend/src/routes/OAuthConsent.svelte create mode 100644 frontend/src/routes/OAuthError.svelte create mode 100644 frontend/src/routes/OAuthLogin.svelte create mode 100644 migrations/20251221_oauth_scope_preferences.sql create mode 100644 src/auth/scope_check.rs create mode 100644 src/oauth/db/scope_preference.rs create mode 100644 src/oauth/scopes/definitions.rs create mode 100644 src/oauth/scopes/error.rs create mode 100644 src/oauth/scopes/mod.rs create mode 100644 src/oauth/scopes/parser.rs create mode 100644 src/oauth/scopes/permissions.rs delete mode 100644 src/oauth/templates.rs create mode 100644 tests/oauth_scopes.rs diff --git a/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json b/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json new file mode 100644 index 0000000..39d788a --- /dev/null +++ b/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_scope_preference (did, client_id, scope, granted, created_at, updated_at)\n VALUES ($1, $2, $3, $4, NOW(), NOW())\n ON CONFLICT (did, client_id, scope) DO UPDATE SET granted = $4, updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114" +} diff --git a/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json b/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json new file mode 100644 index 0000000..2b7ef22 --- /dev/null +++ b/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT scope, granted FROM oauth_scope_preference\n WHERE did = $1 AND client_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scope", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "granted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f" +} diff --git a/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json b/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json new file mode 100644 index 0000000..d763261 --- /dev/null +++ b/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "repo_root_cid", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe" +} diff --git a/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json b/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json new file mode 100644 index 0000000..bb65a3d --- /dev/null +++ b/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING seq\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "seq", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "TextArray", + "TextArray", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e" +} diff --git a/.sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json b/.sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json similarity index 64% rename from .sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json rename to .sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json index 626b011..775550e 100644 --- a/.sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json +++ b/.sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, deactivated_at, takedown_ref FROM users WHERE did = $1", + "query": "SELECT id, handle, deactivated_at, takedown_ref FROM users WHERE did = $1", "describe": { "columns": [ { @@ -10,11 +10,16 @@ }, { "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, "name": "deactivated_at", "type_info": "Timestamptz" }, { - "ordinal": 2, + "ordinal": 3, "name": "takedown_ref", "type_info": "Text" } @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, true, true ] }, - "hash": "c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002" + "hash": "2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d" } diff --git a/.sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json b/.sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json similarity index 57% rename from .sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json rename to .sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json index bcc3dc1..40c18af 100644 --- a/.sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json +++ b/.sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING seq\n ", + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $2, $3, $4, $5)\n RETURNING seq\n ", "describe": { "columns": [ { @@ -11,19 +11,16 @@ ], "parameters": { "Left": [ - "Text", - "Text", "Text", "Text", "Jsonb", "TextArray", - "TextArray", - "Text" + "TextArray" ] }, "nullable": [ false ] }, - "hash": "d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f" + "hash": "53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe" } diff --git a/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json b/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json new file mode 100644 index 0000000..9771f16 --- /dev/null +++ b/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_authorization_request\n SET parameters = jsonb_set(parameters, '{scope}', to_jsonb($2::text))\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91" +} diff --git a/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json b/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json new file mode 100644 index 0000000..0b2acfc --- /dev/null +++ b/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_scope_preference\n WHERE did = $1 AND client_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0" +} diff --git a/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json b/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json deleted file mode 100644 index 259fb57..0000000 --- a/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "channel: CommsChannel", - "type_info": { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c" -} diff --git a/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json b/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json new file mode 100644 index 0000000..38d12db --- /dev/null +++ b/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c" +} diff --git a/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json b/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json new file mode 100644 index 0000000..413d359 --- /dev/null +++ b/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT deactivated_at IS NULL FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b" +} diff --git a/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json b/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json new file mode 100644 index 0000000..4c2852f --- /dev/null +++ b/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM oauth_token WHERE did = $1 AND client_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd" +} diff --git a/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json b/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json deleted file mode 100644 index 152d885..0000000 --- a/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT 1 as one FROM users WHERE handle = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "one", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d" -} diff --git a/Cargo.lock b/Cargo.lock index 3f80afb..9690e7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6207,6 +6207,7 @@ dependencies = [ "serde_bytes", "serde_ipld_dagcbor", "serde_json", + "serde_urlencoded", "sha2", "sqlx", "subtle", diff --git a/Cargo.toml b/Cargo.toml index 37b7a7e..c58edad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ serde_bytes = "0.11.14" serde_ipld_dagcbor = "0.6.4" ipld-core = "0.4.2" serde_json = "1.0.145" +serde_urlencoded = "0.7" sha2 = "0.10.9" subtle = "2.5" p256 = { version = "0.13", features = ["ecdsa"] } diff --git a/TODO.md b/TODO.md index 9cfb3dc..0900b66 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,6 @@ ## Active development -### OAuth scope authorization UI -Display and manage OAuth scopes during authorization flows. - -- [ ] Parse and display requested scopes from authorization request -- [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read") -- [ ] Group scopes by category (read, write, admin, etc.) -- [ ] Allow users to uncheck optional scopes before authorizing -- [ ] Distinguish required vs optional scopes in UI -- [ ] Remember scope preferences per client (don't ask again for same scopes) -- [ ] Token endpoint respects user's scope selections -- [ ] Protected endpoints check token scopes before allowing operations - ### Frontend So like... make the thing unique, make it cool. @@ -90,10 +78,12 @@ Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, app OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification. +OAuth Scope Enforcement: Full granular scope system with consent UI, human-readable scope descriptions, per-client scope preferences, scope parsing (repo/blob/rpc/account/identity), endpoint-level scope checks, DPoP token support in auth extractors, token revocation on re-authorization, response_mode support (query/fragment). + App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback). Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening. -Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel. +Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection. Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e207a58..b6640c2 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -13,6 +13,11 @@ import Notifications from './routes/Notifications.svelte' import RepoExplorer from './routes/RepoExplorer.svelte' import Admin from './routes/Admin.svelte' + import OAuthConsent from './routes/OAuthConsent.svelte' + import OAuthLogin from './routes/OAuthLogin.svelte' + import OAuthAccounts from './routes/OAuthAccounts.svelte' + import OAuth2FA from './routes/OAuth2FA.svelte' + import OAuthError from './routes/OAuthError.svelte' const auth = getAuthState() @@ -46,6 +51,16 @@ return RepoExplorer case '/admin': return Admin + case '/oauth/consent': + return OAuthConsent + case '/oauth/login': + return OAuthLogin + case '/oauth/accounts': + return OAuthAccounts + case '/oauth/2fa': + return OAuth2FA + case '/oauth/error': + return OAuthError default: return auth.session ? Dashboard : Login } diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts index 1d113b0..ce6c05f 100644 --- a/frontend/src/lib/router.svelte.ts +++ b/frontend/src/lib/router.svelte.ts @@ -1,7 +1,12 @@ -let currentPath = $state(window.location.hash.slice(1) || '/') +let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/')) + +function getPathWithoutQuery(hash: string): string { + const queryIndex = hash.indexOf('?') + return queryIndex === -1 ? hash : hash.slice(0, queryIndex) +} window.addEventListener('hashchange', () => { - currentPath = window.location.hash.slice(1) || '/' + currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/') }) export function navigate(path: string) { diff --git a/frontend/src/routes/OAuth2FA.svelte b/frontend/src/routes/OAuth2FA.svelte new file mode 100644 index 0000000..1c44f61 --- /dev/null +++ b/frontend/src/routes/OAuth2FA.svelte @@ -0,0 +1,213 @@ + + +
+

Two-Factor Authentication

+

+ A verification code has been sent to your {channel}. + Enter the code below to continue. +

+ + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ + +
+
+
+ + diff --git a/frontend/src/routes/OAuthAccounts.svelte b/frontend/src/routes/OAuthAccounts.svelte new file mode 100644 index 0000000..3341087 --- /dev/null +++ b/frontend/src/routes/OAuthAccounts.svelte @@ -0,0 +1,264 @@ + + +
+ {#if loading} +
+

Loading accounts...

+
+ {:else if error} +
+

Error

+
{error}
+ +
+ {:else} +

Choose an Account

+

Select an account to continue

+ +
+ {#each accounts as account} + + {/each} +
+ + + {/if} +
+ + diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte new file mode 100644 index 0000000..3cf35db --- /dev/null +++ b/frontend/src/routes/OAuthConsent.svelte @@ -0,0 +1,451 @@ + + + + + diff --git a/frontend/src/routes/OAuthError.svelte b/frontend/src/routes/OAuthError.svelte new file mode 100644 index 0000000..c5c5ce8 --- /dev/null +++ b/frontend/src/routes/OAuthError.svelte @@ -0,0 +1,81 @@ + + +
+

Authorization Error

+ +
+
{error}
+ {#if errorDescription} +
{errorDescription}
+ {/if} +
+ + +
+ + diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte new file mode 100644 index 0000000..9f4ffbb --- /dev/null +++ b/frontend/src/routes/OAuthLogin.svelte @@ -0,0 +1,269 @@ + + +
+

Sign In

+

Sign in to continue to the application

+ + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ + +
+ + + +
+ + +
+
+
+ + diff --git a/migrations/20251221_oauth_scope_preferences.sql b/migrations/20251221_oauth_scope_preferences.sql new file mode 100644 index 0000000..01477a1 --- /dev/null +++ b/migrations/20251221_oauth_scope_preferences.sql @@ -0,0 +1,12 @@ +CREATE TABLE oauth_scope_preference ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + client_id TEXT NOT NULL, + scope TEXT NOT NULL, + granted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(did, client_id, scope) +); + +CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id); diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs index 6509620..77b5ef5 100644 --- a/src/api/actor/preferences.rs +++ b/src/api/actor/preferences.rs @@ -32,16 +32,17 @@ pub async fn get_preferences( .into_response(); } }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) .fetch_optional(&state.db) @@ -109,30 +110,33 @@ pub async fn put_preferences( .into_response(); } }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; - let (user_id, is_migration): (uuid::Uuid, bool) = - match sqlx::query!("SELECT id, deactivated_at FROM users WHERE did = $1", auth_user.did) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => (row.id, row.deactivated_at.is_some()), - _ => { + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(_) => { return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), ) .into_response(); } }; + let (user_id, is_migration): (uuid::Uuid, bool) = match sqlx::query!( + "SELECT id, deactivated_at FROM users WHERE did = $1", + auth_user.did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => (row.id, row.deactivated_at.is_some()), + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User not found"})), + ) + .into_response(); + } + }; if input.preferences.len() > MAX_PREFERENCES_COUNT { return ( StatusCode::BAD_REQUEST, diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 48db175..ff2e83d 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -93,9 +93,8 @@ fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec { .map(|q| { q.split('&') .filter_map(|pair| { - let mut parts = pair.splitn(2, '='); - let k = parts.next()?; - let v = parts.next()?; + let (k, v) = pair.split_once('=')?; + if k == key { Some(urlencoding::decode(v).ok()?.into_owned()) } else { diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs index 05612ff..295a2c4 100644 --- a/src/api/admin/account/search.rs +++ b/src/api/admin/account/search.rs @@ -54,7 +54,17 @@ pub async fn search_accounts( let limit = params.limit.clamp(1, 100); let cursor_did = params.cursor.as_deref().unwrap_or(""); let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); - let result = sqlx::query_as::<_, (String, String, Option, chrono::DateTime, bool, Option>)>( + let result = sqlx::query_as::< + _, + ( + String, + String, + Option, + chrono::DateTime, + bool, + Option>, + ), + >( r#" SELECT did, handle, email, created_at, email_verified, deactivated_at FROM users @@ -74,19 +84,23 @@ pub async fn search_accounts( let accounts: Vec = rows .into_iter() .take(limit as usize) - .map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView { - did: did.clone(), - handle, - email, - indexed_at: created_at.to_rfc3339(), - email_verified_at: if email_verified { - Some(created_at.to_rfc3339()) - } else { - None + .map( + |(did, handle, email, created_at, email_verified, deactivated_at)| { + AccountView { + did: did.clone(), + handle, + email, + indexed_at: created_at.to_rfc3339(), + email_verified_at: if email_verified { + Some(created_at.to_rfc3339()) + } else { + None + }, + deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()), + invites_disabled: None, + } }, - deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()), - invites_disabled: None, - }) + ) .collect(); let next_cursor = if has_more { accounts.last().map(|a| a.did.clone()) diff --git a/src/api/admin/server_stats.rs b/src/api/admin/server_stats.rs index c9da10b..12d7b72 100644 --- a/src/api/admin/server_stats.rs +++ b/src/api/admin/server_stats.rs @@ -16,10 +16,7 @@ pub struct ServerStatsResponse { pub blob_storage_bytes: i64, } -pub async fn get_server_stats( - State(state): State, - _auth: BearerAuthAdmin, -) -> Response { +pub async fn get_server_stats(State(state): State, _auth: BearerAuthAdmin) -> Response { let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users") .fetch_one(&state.db) .await @@ -47,14 +44,15 @@ pub async fn get_server_stats( Err(_) => 0, }; - let blob_storage_bytes: i64 = match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs") - .fetch_one(&state.db) - .await - { - Ok(Some(bytes)) => bytes, - Ok(None) => 0, - Err(_) => 0, - }; + let blob_storage_bytes: i64 = + match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs") + .fetch_one(&state.db) + .await + { + Ok(Some(bytes)) => bytes, + Ok(None) => 0, + Err(_) => 0, + }; Json(ServerStatsResponse { user_count, diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index b994c43..a270b6d 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -21,13 +21,15 @@ 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(); - } + && 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(); - } + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } "unknown".to_string() } @@ -114,7 +116,11 @@ pub async fn create_account( }; let is_migration = migration_auth.is_some() - && input.did.as_ref().map(|d| d.starts_with("did:plc:")).unwrap_or(false); + && input + .did + .as_ref() + .map(|d| d.starts_with("did:plc:")) + .unwrap_or(false); if is_migration { let migration_did = input.did.as_ref().unwrap(); @@ -147,13 +153,14 @@ pub async fn create_account( .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); if let Some(ref email) = email - && !crate::api::validation::is_valid_email(email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); - } + && !crate::api::validation::is_valid_email(email) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + ) + .into_response(); + } let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); let valid_channels = ["email", "discord", "telegram", "signal"]; if !valid_channels.contains(&verification_channel) && !is_migration { @@ -366,32 +373,32 @@ pub async fn create_account( }; if is_migration { let existing_account: Option<(uuid::Uuid, String, Option>)> = - sqlx::query_as( - "SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE", - ) - .bind(&did) - .fetch_optional(&mut *tx) - .await - .unwrap_or(None); + sqlx::query_as("SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE") + .bind(&did) + .fetch_optional(&mut *tx) + .await + .unwrap_or(None); if let Some((account_id, old_handle, deactivated_at)) = existing_account { if deactivated_at.is_some() { info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); - let update_result: Result<_, sqlx::Error> = sqlx::query( - "UPDATE users SET handle = $1 WHERE id = $2", - ) - .bind(short_handle) - .bind(account_id) - .execute(&mut *tx) - .await; + let update_result: Result<_, sqlx::Error> = + sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") + .bind(short_handle) + .bind(account_id) + .execute(&mut *tx) + .await; if let Err(e) = update_result { - if let Some(db_err) = e.as_database_error() { - if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) { - return ( + if let Some(db_err) = e.as_database_error() + && db_err + .constraint() + .map(|c| c.contains("handle")) + .unwrap_or(false) + { + return ( StatusCode::BAD_REQUEST, Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})), ) .into_response(); - } } error!("Error reactivating account: {:?}", e); return ( @@ -438,18 +445,22 @@ pub async fn create_account( .into_response(); } }; - let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Error creating access token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { + let access_meta = + match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Error creating access token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let refresh_meta = match crate::auth::create_refresh_token_with_metadata( + &did, + &secret_key_bytes, + ) { Ok(m) => m, Err(e) => { error!("Error creating refresh token: {:?}", e); @@ -499,13 +510,12 @@ pub async fn create_account( } } } - let exists_result: Option<(i32,)> = sqlx::query_as( - "SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL", - ) - .bind(short_handle) - .fetch_optional(&mut *tx) - .await - .unwrap_or(None); + let exists_result: Option<(i32,)> = + sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") + .bind(short_handle) + .fetch_optional(&mut *tx) + .await + .unwrap_or(None); if exists_result.is_some() { return ( StatusCode::BAD_REQUEST, @@ -516,50 +526,41 @@ pub async fn create_account( let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") .unwrap_or(false); - if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) { + if invite_code_required + && input + .invite_code + .as_ref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true) + { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})), ) .into_response(); } - if let Some(code) = &input.invite_code { - if !code.trim().is_empty() { - let invite_query = sqlx::query!( - "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", - code - ) - .fetch_optional(&mut *tx) - .await; - match invite_query { - Ok(Some(row)) => { - if row.available_uses <= 0 { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); - } - let update_invite = sqlx::query!( - "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", - code - ) - .execute(&mut *tx) - .await; - if let Err(e) = update_invite { - error!("Error updating invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + if let Some(code) = &input.invite_code + && !code.trim().is_empty() + { + let invite_query = sqlx::query!( + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", + code + ) + .fetch_optional(&mut *tx) + .await; + match invite_query { + Ok(Some(row)) => { + if row.available_uses <= 0 { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); } - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), - ) - .into_response(); - } - Err(e) => { - error!("Error checking invite code: {:?}", e); + let update_invite = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + if let Err(e) = update_invite { + error!("Error updating invite code: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -567,6 +568,21 @@ pub async fn create_account( .into_response(); } } + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), + ) + .into_response(); + } + Err(e) => { + error!("Error checking invite code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } } } let password_hash = match hash(&input.password, DEFAULT_COST) { @@ -635,37 +651,38 @@ pub async fn create_account( Ok((id,)) => id, Err(e) => { if let Some(db_err) = e.as_database_error() - && db_err.code().as_deref() == Some("23505") { - let constraint = db_err.constraint().unwrap_or(""); - if constraint.contains("handle") || constraint.contains("users_handle") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "HandleNotAvailable", - "message": "Handle already taken" - })), - ) - .into_response(); - } else if constraint.contains("email") || constraint.contains("users_email") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidEmail", - "message": "Email already registered" - })), - ) - .into_response(); - } else if constraint.contains("did") || constraint.contains("users_did") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "AccountAlreadyExists", - "message": "An account with this DID already exists" - })), - ) - .into_response(); - } + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") || constraint.contains("users_handle") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "HandleNotAvailable", + "message": "Handle already taken" + })), + ) + .into_response(); + } else if constraint.contains("email") || constraint.contains("users_email") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidEmail", + "message": "Email already registered" + })), + ) + .into_response(); + } else if constraint.contains("did") || constraint.contains("users_did") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "AccountAlreadyExists", + "message": "An account with this DID already exists" + })), + ) + .into_response(); } + } error!("Error inserting user: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -675,8 +692,8 @@ pub async fn create_account( } }; - if !is_migration { - if let Err(e) = sqlx::query!( + if !is_migration + && let Err(e) = sqlx::query!( "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", user_id, verification_code, @@ -692,7 +709,6 @@ pub async fn create_account( ) .into_response(); } - } let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { Ok(enc) => enc, Err(e) => { @@ -809,23 +825,23 @@ pub async fn create_account( ) .into_response(); } - if let Some(code) = &input.invite_code { - if !code.trim().is_empty() { - let use_insert = sqlx::query!( - "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", - code, - user_id + if let Some(code) = &input.invite_code + && !code.trim().is_empty() + { + let use_insert = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + if let Err(e) = use_insert { + error!("Error recording invite usage: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), ) - .execute(&mut *tx) - .await; - if let Err(e) = use_insert { - error!("Error recording invite usage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + .into_response(); } } if let Err(e) = tx.commit().await { @@ -838,11 +854,13 @@ pub async fn create_account( } if !is_migration { if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_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, true, None).await { warn!("Failed to sequence account event for {}: {}", did, e); } @@ -861,8 +879,8 @@ pub async fn create_account( { warn!("Failed to create default profile for {}: {}", did, e); } - if let Some(ref recipient) = verification_recipient { - if let Err(e) = crate::comms::enqueue_signup_verification( + if let Some(ref recipient) = verification_recipient + && let Err(e) = crate::comms::enqueue_signup_verification( &state.db, user_id, verification_channel, @@ -870,12 +888,11 @@ pub async fn create_account( &verification_code, ) .await - { - warn!( - "Failed to enqueue signup verification notification: {:?}", - e - ); - } + { + warn!( + "Failed to enqueue signup verification notification: {:?}", + e + ); } } diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index cad0e84..1920d3a 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -54,22 +54,20 @@ pub async fn resolve_handle( .await; (StatusCode::OK, Json(json!({ "did": row.did }))).into_response() } - Ok(None) => { - match crate::handle::resolve_handle(handle).await { - Ok(did) => { - let _ = state - .cache - .set(&cache_key, &did, std::time::Duration::from_secs(300)) - .await; - (StatusCode::OK, Json(json!({ "did": did }))).into_response() - } - Err(_) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), - ) - .into_response(), + Ok(None) => match crate::handle::resolve_handle(handle).await { + Ok(did) => { + let _ = state + .cache + .set(&cache_key, &did, std::time::Duration::from_secs(300)) + .await; + (StatusCode::OK, Json(json!({ "did": did }))).into_response() } - } + Err(_) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), + ) + .into_response(), + }, Err(e) => { error!("DB error resolving handle: {:?}", e); ( @@ -310,10 +308,11 @@ pub async fn get_recommended_did_credentials( .into_response(); } }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; let user = match sqlx::query!( "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", auth_user.did @@ -378,10 +377,19 @@ pub async fn update_handle( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user.did, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if let Err(e) = crate::auth::scope_check::check_identity_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::IdentityAttr::Handle, + ) { + return e; + } + let did = auth_user.did; let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -414,7 +422,10 @@ pub async fn update_handle( } else { new_handle }; - (short_handle.to_string(), format!("{}.{}", short_handle, hostname)) + ( + short_handle.to_string(), + format!("{}.{}", short_handle, hostname), + ) } else { match crate::handle::verify_handle_ownership(new_handle, &did).await { Ok(()) => {} @@ -537,7 +548,8 @@ async fn update_plc_handle( let plc_client = crate::plc::PlcClient::new(None); let last_op = plc_client.get_last_op(did).await?; let new_also_known_as = vec![format!("at://{}", new_handle)]; - let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?; + let update_op = + crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?; let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?; plc_client.send_operation(did, &signed_op).await?; Ok(()) diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs index e8cb559..4d6e37a 100644 --- a/src/api/identity/plc/request.rs +++ b/src/api/identity/plc/request.rs @@ -24,10 +24,18 @@ pub async fn request_plc_operation_signature( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if let Err(e) = crate::auth::scope_check::check_identity_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::IdentityAttr::Wildcard, + ) { + return e; + } let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did) .fetch_optional(&state.db) .await diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs index 445b6bf..333d04d 100644 --- a/src/api/identity/plc/sign.rs +++ b/src/api/identity/plc/sign.rs @@ -50,10 +50,18 @@ pub async fn sign_plc_operation( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if let Err(e) = crate::auth::scope_check::check_identity_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::IdentityAttr::Wildcard, + ) { + return e; + } let did = &auth_user.did; let token = match &input.token { Some(t) => t, diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index 9e27dc0..112ab5c 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -29,10 +29,18 @@ pub async fn submit_plc_operation( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if let Err(e) = crate::auth::scope_check::check_identity_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::IdentityAttr::Wildcard, + ) { + return e; + } let did = &auth_user.did; if let Err(e) = validate_plc_operation(&input.operation) { return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response(); @@ -40,9 +48,12 @@ pub async fn submit_plc_operation( let op = &input.operation; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let public_url = format!("https://{}", hostname); - let user = match sqlx::query!("SELECT id, handle, deactivated_at FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await + let user = match sqlx::query!( + "SELECT id, handle, deactivated_at FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await { Ok(Some(row)) => row, _ => { @@ -94,63 +105,65 @@ pub async fn submit_plc_operation( } }; let user_did_key = signing_key_to_did_key(&signing_key); - if !is_migration { - if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { - let server_rotation_key = - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); - let has_server_key = rotation_keys - .iter() - .any(|k| k.as_str() == Some(&server_rotation_key)); - if !has_server_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Rotation keys do not include server's rotation key" - })), - ) - .into_response(); - } + if !is_migration && let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) + { + let server_rotation_key = + std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); + let has_server_key = rotation_keys + .iter() + .any(|k| k.as_str() == Some(&server_rotation_key)); + if !has_server_key { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Rotation keys do not include server's rotation key" + })), + ) + .into_response(); } } if let Some(services) = op.get("services").and_then(|v| v.as_object()) - && let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) { - let service_type = pds.get("type").and_then(|v| v.as_str()); - let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); - if service_type != Some("AtprotoPersonalDataServer") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect type on atproto_pds service" - })), - ) - .into_response(); - } - if endpoint != Some(&public_url) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect endpoint on atproto_pds service" - })), - ) - .into_response(); - } + && let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) + { + let service_type = pds.get("type").and_then(|v| v.as_str()); + let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); + if service_type != Some("AtprotoPersonalDataServer") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect type on atproto_pds service" + })), + ) + .into_response(); } + if endpoint != Some(&public_url) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect endpoint on atproto_pds service" + })), + ) + .into_response(); + } + } if !is_migration { - if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) + if let Some(verification_methods) = + op.get("verificationMethods").and_then(|v| v.as_object()) && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) - && atproto_key != user_did_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect signing key in verificationMethods" - })), - ) - .into_response(); - } + && atproto_key != user_did_key + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect signing key in verificationMethods" + })), + ) + .into_response(); + } if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { let expected_handle = format!("at://{}", user.handle); let first_aka = also_known_as.first().and_then(|v| v.as_str()); diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 989302c..8c7fca0 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -147,20 +147,24 @@ pub async fn get_notification_history( } }; - let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) - .fetch_one(&state.db) - .await - { - Ok(id) => id, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(), - }; + let user_id: uuid::Uuid = + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) + .fetch_one(&state.db) + .await + { + Ok(id) => id, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": "InternalError", "message": format!("Database error: {}", e)}), + ), + ) + .into_response(), + }; - let rows = match sqlx::query!( - r#" + let rows = + match sqlx::query!( + r#" SELECT created_at, channel as "channel: String", @@ -173,29 +177,32 @@ pub async fn get_notification_history( ORDER BY created_at DESC LIMIT 50 "#, - user_id - ) - .fetch_all(&state.db) - .await - { - Ok(r) => r, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + user_id ) - .into_response(), - }; + .fetch_all(&state.db) + .await + { + Ok(r) => r, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": "InternalError", "message": format!("Database error: {}", e)}), + ), + ) + .into_response(), + }; - let notifications = rows.iter().map(|row| { - NotificationHistoryEntry { + let notifications = rows + .iter() + .map(|row| NotificationHistoryEntry { created_at: row.created_at.to_rfc3339(), channel: row.channel.clone(), comms_type: row.comms_type.clone(), status: row.status.clone(), subject: row.subject.clone(), body: row.body.clone(), - } - }).collect(); + }) + .collect(); Json(GetNotificationHistoryResponse { notifications }).into_response() } @@ -297,20 +304,23 @@ pub async fn update_notification_prefs( } }; - let user_row = match sqlx::query!( - "SELECT id, handle, email FROM users WHERE did = $1", - user.did - ) - .fetch_one(&state.db) - .await - { - Ok(row) => row, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + let user_row = + match sqlx::query!( + "SELECT id, handle, email FROM users WHERE did = $1", + user.did ) - .into_response(), - }; + .fetch_one(&state.db) + .await + { + Ok(row) => row, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + json!({"error": "InternalError", "message": format!("Database error: {}", e)}), + ), + ) + .into_response(), + }; let user_id = user_row.id; let handle = user_row.handle; @@ -384,7 +394,15 @@ pub async fn update_notification_prefs( .into_response(); } - if let Err(e) = request_channel_verification(&state.db, user_id, "email", &email_clean, Some(&handle)).await { + if let Err(e) = request_channel_verification( + &state.db, + user_id, + "email", + &email_clean, + Some(&handle), + ) + .await + { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e})), @@ -419,7 +437,9 @@ pub async fn update_notification_prefs( .await; info!(did = %user.did, "Cleared Discord ID"); } else { - if let Err(e) = request_channel_verification(&state.db, user_id, "discord", discord_id, None).await { + if let Err(e) = + request_channel_verification(&state.db, user_id, "discord", discord_id, None).await + { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e})), @@ -455,7 +475,10 @@ pub async fn update_notification_prefs( .await; info!(did = %user.did, "Cleared Telegram username"); } else { - if let Err(e) = request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None).await { + if let Err(e) = + request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None) + .await + { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e})), @@ -490,7 +513,9 @@ pub async fn update_notification_prefs( .await; info!(did = %user.did, "Cleared Signal number"); } else { - if let Err(e) = request_channel_verification(&state.db, user_id, "signal", signal, None).await { + if let Err(e) = + request_channel_verification(&state.db, user_id, "signal", signal, None).await + { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e})), @@ -505,5 +530,6 @@ pub async fn update_notification_prefs( Json(UpdateNotificationPrefsResponse { success: true, verification_required, - }).into_response() + }) + .into_response() } diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 31725d6..243ccba 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -18,10 +18,7 @@ pub async fn proxy_handler( RawQuery(query): RawQuery, body: Bytes, ) -> Response { - let proxy_header = match headers - .get("atproto-proxy") - .and_then(|h| h.to_str().ok()) - { + let proxy_header = match headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) { Some(h) => h.to_string(), None => { return ( @@ -66,6 +63,15 @@ pub async fn proxy_handler( ) { match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(auth_user) => { + if let Err(e) = crate::auth::scope_check::check_rpc_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + &resolved.did, + &method, + ) { + return e; + } + if let Some(key_bytes) = auth_user.key_bytes { match crate::auth::create_service_token( &auth_user.did, diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index ab45c1b..34dac58 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -62,6 +62,17 @@ pub async fn upload_blob( } else { match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => { + 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 e; + } let deactivated = sqlx::query_scalar!( "SELECT deactivated_at FROM users WHERE did = $1", user.did @@ -171,23 +182,22 @@ pub async fn upload_blob( .blob_store .put_bytes(&storage_key, bytes::Bytes::from(data)) .await - { - error!("Failed to upload blob to storage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to store blob"})), - ) - .into_response(); - } + { + error!("Failed to upload blob to storage: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to store blob"})), + ) + .into_response(); + } if let Err(e) = tx.commit().await { error!("Failed to commit blob transaction: {:?}", e); - if was_inserted - && let Err(cleanup_err) = state.blob_store.delete(&storage_key).await { - error!( - "Failed to cleanup orphaned blob {}: {:?}", - storage_key, cleanup_err - ); - } + if was_inserted && let Err(cleanup_err) = state.blob_store.delete(&storage_key).await { + error!( + "Failed to cleanup orphaned blob {}: {:?}", + storage_key, cleanup_err + ); + } return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -231,11 +241,12 @@ fn find_blobs(val: &serde_json::Value, blobs: &mut Vec) { if let Some(obj) = val.as_object() { if let Some(type_val) = obj.get("$type") && type_val == "blob" - && let Some(r) = obj.get("ref") - && let Some(link) = r.get("$link") - && let Some(s) = link.as_str() { - blobs.push(s.to_string()); - } + && let Some(r) = obj.get("ref") + && let Some(link) = r.get("$link") + && let Some(s) = link.as_str() + { + blobs.push(s.to_string()); + } for (_, v) in obj { find_blobs(v, blobs); } diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index 94acb68..656853e 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -53,13 +53,14 @@ pub async fn import_repo( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; let did = &auth_user.did; let user = match sqlx::query!( - "SELECT id, deactivated_at, takedown_ref FROM users WHERE did = $1", + "SELECT id, handle, deactivated_at, takedown_ref FROM users WHERE did = $1", did ) .fetch_optional(&state.db) @@ -317,6 +318,30 @@ pub async fn import_repo( records.len(), did ); + if is_migration { + if let Err(e) = + sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) + .execute(&state.db) + .await + { + error!("Failed to reactivate account after import: {:?}", e); + } + let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; + if let Err(e) = crate::api::repo::record::sequence_identity_event( + &state, + did, + Some(&user.handle), + ) + .await + { + warn!("Failed to sequence identity event after import: {:?}", e); + } + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, did, true, None).await + { + warn!("Failed to sequence account event after import: {:?}", e); + } + } if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { warn!("Failed to sequence import event: {:?}", e); } diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 43a2295..3810062 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -101,7 +101,9 @@ pub async fn apply_writes( .into_response(); } }; - let did = auth_user.did; + let did = auth_user.did.clone(); + let is_oauth = auth_user.is_oauth; + let scope = auth_user.scope; if input.repo != did { return ( StatusCode::FORBIDDEN, @@ -144,6 +146,75 @@ pub async fn apply_writes( ) .into_response(); } + + if is_oauth { + use std::collections::HashSet; + let create_collections: HashSet<&str> = input + .writes + .iter() + .filter_map(|w| { + if let WriteOp::Create { collection, .. } = w { + Some(collection.as_str()) + } else { + None + } + }) + .collect(); + let update_collections: HashSet<&str> = input + .writes + .iter() + .filter_map(|w| { + if let WriteOp::Update { collection, .. } = w { + Some(collection.as_str()) + } else { + None + } + }) + .collect(); + let delete_collections: HashSet<&str> = input + .writes + .iter() + .filter_map(|w| { + if let WriteOp::Delete { collection, .. } = w { + Some(collection.as_str()) + } else { + None + } + }) + .collect(); + + for collection in create_collections { + if let Err(e) = crate::auth::scope_check::check_repo_scope( + is_oauth, + scope.as_deref(), + crate::oauth::RepoAction::Create, + collection, + ) { + return e; + } + } + for collection in update_collections { + if let Err(e) = crate::auth::scope_check::check_repo_scope( + is_oauth, + scope.as_deref(), + crate::oauth::RepoAction::Update, + collection, + ) { + return e; + } + } + for collection in delete_collections { + if let Err(e) = crate::auth::scope_check::check_repo_scope( + is_oauth, + scope.as_deref(), + crate::oauth::RepoAction::Delete, + collection, + ) { + return e; + } + } + } + let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -184,13 +255,14 @@ pub async fn apply_writes( } }; if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); - } + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), + ) + .into_response(); + } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, @@ -225,9 +297,10 @@ pub async fn apply_writes( value, } => { if input.validate.unwrap_or(true) - && let Err(err_response) = validate_record(value, collection) { - return *err_response; - } + && let Err(err_response) = validate_record(value, collection) + { + return *err_response; + } let rkey = rkey .clone() .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); @@ -276,9 +349,10 @@ pub async fn apply_writes( value, } => { if input.validate.unwrap_or(true) - && let Err(err_response) = validate_record(value, collection) { - return *err_response; - } + && let Err(err_response) = validate_record(value, collection) + { + return *err_response; + } let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); @@ -353,7 +427,11 @@ pub async fn apply_writes( }; let mut relevant_blocks = std::collections::BTreeMap::new(); for key in &modified_keys { - if mst.blocks_for_path(key, &mut relevant_blocks).await.is_err() { + if mst + .blocks_for_path(key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } if original_mst diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index 63cf961..7a82f3b 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -34,19 +34,34 @@ pub async fn delete_record( axum::extract::OriginalUri(uri): axum::extract::OriginalUri, Json(input): Json, ) -> Response { - let (did, user_id, current_root_cid) = + let auth = match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { Ok(res) => res, Err(err_res) => return err_res, }; + + if let Err(e) = crate::auth::scope_check::check_repo_scope( + auth.is_oauth, + auth.scope.as_deref(), + crate::oauth::RepoAction::Delete, + &input.collection, + ) { + return e; + } + + let did = auth.did; + let user_id = auth.user_id; + let current_root_cid = auth.current_root_cid; + if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); - } + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), + ) + .into_response(); + } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, @@ -115,10 +130,18 @@ pub async fn delete_record( prev: prev_record_cid, }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if new_mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } let mut written_cids = tracking_store.get_all_relevant_cids(); diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index f79080c..b5ed253 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -48,10 +48,7 @@ pub async fn get_record( let user_id: uuid::Uuid = match user_id_opt { Ok(Some(id)) => id, Ok(None) => { - if let Some(proxy_header) = headers - .get("atproto-proxy") - .and_then(|h| h.to_str().ok()) - { + if let Some(proxy_header) = headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) { let did = proxy_header.split('#').next().unwrap_or(proxy_header); if let Some(resolved) = state.did_resolver.resolve_did(did).await { let mut url = format!( @@ -84,7 +81,8 @@ pub async fn get_record( .header("content-type", "application/json") .body(axum::body::Body::from(body)) .unwrap_or_else(|_| { - (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error") + .into_response() }); } Err(e) => { @@ -138,13 +136,14 @@ pub async fn get_record( } }; if let Some(expected_cid) = &input.cid - && &record_cid_str != expected_cid { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Record CID mismatch"})), - ) - .into_response(); - } + && &record_cid_str != expected_cid + { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "NotFound", "message": "Record CID mismatch"})), + ) + .into_response(); + } let cid = match Cid::from_str(&record_cid_str) { Ok(c) => c, Err(_) => { @@ -326,13 +325,14 @@ pub async fn list_records( for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) { if let Some(block) = block_opt && let Some((rkey, cid_str)) = cid_to_rkey.get(cid) - && let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { - records.push(json!({ - "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), - "cid": cid_str, - "value": value - })); - } + && let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) + { + records.push(json!({ + "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), + "cid": cid_str, + "value": value + })); + } } Json(ListRecordsOutput { cursor: last_rkey, diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index bf6089c..6e20177 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -151,27 +151,36 @@ pub async fn commit_and_log( match lock_result { Err(e) => { if let Some(db_err) = e.as_database_error() - && db_err.code().as_deref() == Some("55P03") { - return Err( - "ConcurrentModification: Another request is modifying this repo" - .to_string(), - ); - } + && db_err.code().as_deref() == Some("55P03") + { + return Err( + "ConcurrentModification: Another request is modifying this repo".to_string(), + ); + } return Err(format!("Failed to acquire repo lock: {}", e)); } Ok(Some(row)) => { if let Some(expected_root) = ¤t_root_cid - && row.repo_root_cid != expected_root.to_string() { - return Err( - "ConcurrentModification: Repo has been modified since last read" - .to_string(), - ); - } + && row.repo_root_cid != expected_root.to_string() + { + return Err( + "ConcurrentModification: Repo has been modified since last read".to_string(), + ); + } } Ok(None) => { return Err("Repo not found".to_string()); } } + let is_account_active = sqlx::query_scalar!( + "SELECT deactivated_at IS NULL FROM users WHERE id = $1", + user_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| format!("Failed to check account status: {}", e))? + .flatten() + .unwrap_or(false); sqlx::query!( "UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), @@ -289,35 +298,39 @@ pub async fn commit_and_log( } }) .collect::>(); - let event_type = "commit"; - let prev_cid_str = current_root_cid.map(|c| c.to_string()); - let prev_data_cid_str = prev_data_cid.map(|c| c.to_string()); - let seq_row = sqlx::query!( - r#" - INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING seq - "#, - did, - event_type, - new_root_cid.to_string(), - prev_cid_str, - json!(ops_json), - &[] as &[String], - blocks_cids, - prev_data_cid_str, - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| format!("DB Error (repo_seq): {}", e))?; - sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) - .execute(&mut *tx) + if is_account_active { + let event_type = "commit"; + let prev_cid_str = current_root_cid.map(|c| c.to_string()); + let prev_data_cid_str = prev_data_cid.map(|c| c.to_string()); + let seq_row = sqlx::query!( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING seq + "#, + did, + event_type, + new_root_cid.to_string(), + prev_cid_str, + json!(ops_json), + &[] as &[String], + blocks_cids, + prev_data_cid_str, + ) + .fetch_one(&mut *tx) .await - .map_err(|e| format!("DB Error (notify): {}", e))?; + .map_err(|e| format!("DB Error (repo_seq): {}", e))?; + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) + .execute(&mut *tx) + .await + .map_err(|e| format!("DB Error (notify): {}", e))?; + } tx.commit() .await .map_err(|e| format!("Failed to commit transaction: {}", e))?; - let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await; + if is_account_active { + let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await; + } Ok(CommitResult { commit_cid: new_root_cid, rev: rev_str, @@ -482,3 +495,37 @@ pub async fn sequence_sync_event( .map_err(|e| format!("DB Error (notify): {}", e))?; Ok(seq_row.seq) } + +pub async fn sequence_empty_commit_event(state: &AppState, did: &str) -> Result { + let repo_root = sqlx::query_scalar!( + "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", + did + ) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("DB Error fetching repo root: {}", e))? + .ok_or_else(|| "Repo not found".to_string())?; + let ops = serde_json::json!([]); + let blobs: Vec = vec![]; + let blocks_cids: Vec = vec![]; + let seq_row = sqlx::query!( + r#" + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids) + VALUES ($1, 'commit', $2, $2, $3, $4, $5) + RETURNING seq + "#, + did, + repo_root, + ops, + &blobs, + &blocks_cids + ) + .fetch_one(&state.db) + .await + .map_err(|e| format!("DB Error (repo_seq empty commit): {}", e))?; + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) + .execute(&state.db) + .await + .map_err(|e| format!("DB Error (notify): {}", e))?; + Ok(seq_row.seq) +} diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index fa2b1f6..d83f1b7 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -22,10 +22,7 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -pub async fn has_verified_comms_channel( - db: &PgPool, - did: &str, -) -> Result { +pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result { let row = sqlx::query( r#" SELECT @@ -52,13 +49,21 @@ pub async fn has_verified_comms_channel( } } +pub struct RepoWriteAuth { + pub did: String, + pub user_id: Uuid, + pub current_root_cid: Cid, + pub is_oauth: bool, + pub scope: Option, +} + pub async fn prepare_repo_write( state: &AppState, headers: &HeaderMap, repo_did: &str, http_method: &str, http_uri: &str, -) -> Result<(String, Uuid, Cid), Response> { +) -> Result { let extracted = crate::auth::extract_auth_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) @@ -69,9 +74,7 @@ pub async fn prepare_repo_write( ) .into_response() })?; - let dpop_proof = headers - .get("DPoP") - .and_then(|h| h.to_str().ok()); + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); let auth_user = crate::auth::validate_token_with_dpop( &state.db, &extracted.token, @@ -163,7 +166,13 @@ pub async fn prepare_repo_write( ) .into_response() })?; - Ok((auth_user.did, user_id, current_root_cid)) + Ok(RepoWriteAuth { + did: auth_user.did, + user_id, + current_root_cid, + is_oauth: auth_user.is_oauth, + scope: auth_user.scope, + }) } #[derive(Deserialize)] #[allow(dead_code)] @@ -188,19 +197,34 @@ pub async fn create_record( axum::extract::OriginalUri(uri): axum::extract::OriginalUri, Json(input): Json, ) -> Response { - let (did, user_id, current_root_cid) = + let auth = match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { Ok(res) => res, Err(err_res) => return err_res, }; + + if let Err(e) = crate::auth::scope_check::check_repo_scope( + auth.is_oauth, + auth.scope.as_deref(), + crate::oauth::RepoAction::Create, + &input.collection, + ) { + return e; + } + + let did = auth.did; + let user_id = auth.user_id; + let current_root_cid = auth.current_root_cid; + if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); - } + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), + ) + .into_response(); + } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, @@ -234,9 +258,10 @@ pub async fn create_record( } }; if input.validate.unwrap_or(true) - && let Err(err_response) = validate_record(&input.record, &input.collection) { - return *err_response; - } + && let Err(err_response) = validate_record(&input.record, &input.collection) + { + return *err_response; + } let rkey = input .rkey .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); @@ -285,10 +310,18 @@ pub async fn create_record( cid: record_cid, }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if new_mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); @@ -356,19 +389,42 @@ pub async fn put_record( axum::extract::OriginalUri(uri): axum::extract::OriginalUri, Json(input): Json, ) -> Response { - let (did, user_id, current_root_cid) = + let auth = match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { Ok(res) => res, Err(err_res) => return err_res, }; + + if let Err(e) = crate::auth::scope_check::check_repo_scope( + auth.is_oauth, + auth.scope.as_deref(), + crate::oauth::RepoAction::Create, + &input.collection, + ) { + return e; + } + if let Err(e) = crate::auth::scope_check::check_repo_scope( + auth.is_oauth, + auth.scope.as_deref(), + crate::oauth::RepoAction::Update, + &input.collection, + ) { + return e; + } + + let did = auth.did; + let user_id = auth.user_id; + let current_root_cid = auth.current_root_cid; + if let Some(swap_commit) = &input.swap_commit - && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); - } + && Cid::from_str(swap_commit).ok() != Some(current_root_cid) + { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), + ) + .into_response(); + } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, @@ -403,9 +459,10 @@ pub async fn put_record( }; let key = format!("{}/{}", collection_nsid, input.rkey); if input.validate.unwrap_or(true) - && let Err(err_response) = validate_record(&input.record, &input.collection) { - return *err_response; - } + && let Err(err_response) = validate_record(&input.record, &input.collection) + { + return *err_response; + } if let Some(swap_record_str) = &input.swap_record { let expected_cid = Cid::from_str(swap_record_str).ok(); let actual_cid = mst.get(&key).await.ok().flatten(); @@ -480,10 +537,18 @@ pub async fn put_record( } }; let mut relevant_blocks = std::collections::BTreeMap::new(); - if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if new_mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); } - if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() { + if mst + .blocks_for_path(&key, &mut relevant_blocks) + .await + .is_err() + { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index ea4f30f..39b92bd 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -133,7 +133,7 @@ pub async fn activate_account( "https://{}/xrpc/com.atproto.server.activateAccount", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ); - let did = match crate::auth::validate_token_with_dpop( + let auth_user = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, extracted.is_dpop, @@ -144,9 +144,20 @@ pub async fn activate_account( ) .await { - Ok(user) => user.did, + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; + + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Repo, + crate::oauth::scopes::AccountAction::Manage, + ) { + return e; + } + + let did = auth_user.did; let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -171,6 +182,14 @@ pub async fn activate_account( { warn!("Failed to sequence identity event for activation: {}", e); } + if let Err(e) = + crate::api::repo::record::sequence_empty_commit_event(&state, &did).await + { + warn!( + "Failed to sequence empty commit event for activation: {}", + e + ); + } (StatusCode::OK, Json(json!({}))).into_response() } Err(e) => { @@ -206,7 +225,7 @@ pub async fn deactivate_account( "https://{}/xrpc/com.atproto.server.deactivateAccount", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ); - let did = match crate::auth::validate_token_with_dpop( + let auth_user = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, extracted.is_dpop, @@ -217,9 +236,20 @@ pub async fn deactivate_account( ) .await { - Ok(user) => user.did, + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; + + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Repo, + crate::oauth::scopes::AccountAction::Manage, + ) { + return e; + } + + let did = auth_user.did; let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -236,8 +266,13 @@ pub async fn deactivate_account( if let Some(ref h) = handle { let _ = state.cache.delete(&format!("handle:{}", h)).await; } - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did, false, Some("deactivated")).await + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + &did, + false, + Some("deactivated"), + ) + .await { warn!("Failed to sequence account deactivation event: {}", e); } @@ -315,13 +350,9 @@ pub async fn request_account_delete( .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::comms::enqueue_account_deletion( - &state.db, - user_id, - &confirmation_token, - &hostname, - ) - .await + if let Err(e) = + crate::comms::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname) + .await { warn!("Failed to enqueue account deletion notification: {:?}", e); } @@ -502,6 +533,19 @@ pub async fn delete_account( ) .into_response(); } + if let Err(e) = crate::api::repo::record::sequence_account_event( + &state, + did, + false, + Some("deleted"), + ) + .await + { + warn!( + "Failed to sequence account deletion event for {}: {}", + did, e + ); + } let _ = state.cache.delete(&format!("handle:{}", handle)).await; info!("Account {} deleted successfully", did); (StatusCode::OK, Json(json!({}))).into_response() diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 1cd9e4a..6c1d582 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -52,11 +52,21 @@ pub async fn request_email_update( }; let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, + let auth_user = match auth_result { + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Email, + crate::oauth::scopes::AccountAction::Manage, + ) { + return e; + } + + let did = auth_user.did; let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -167,11 +177,21 @@ pub async fn confirm_email( }; let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, + let auth_user = match auth_result { + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Email, + crate::oauth::scopes::AccountAction::Manage, + ) { + return e; + } + + let did = auth_user.did; let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_one(&state.db) .await @@ -274,7 +294,7 @@ pub async fn confirm_email( return ApiError::InternalError.into_response(); } - if let Err(_) = tx.commit().await { + if tx.commit().await.is_err() { return ApiError::InternalError.into_response(); } @@ -310,17 +330,24 @@ pub async fn update_email( }; let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, + let auth_user = match auth_result { + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; - let user = match sqlx::query!( - "SELECT id, email FROM users WHERE did = $1", - did - ) - .fetch_optional(&state.db) - .await + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Email, + crate::oauth::scopes::AccountAction::Manage, + ) { + return e; + } + + let did = auth_user.did; + let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await { Ok(Some(row)) => row, _ => { @@ -451,7 +478,7 @@ pub async fn update_email( .execute(&mut *tx) .await; - if let Err(_) = tx.commit().await { + if tx.commit().await.is_err() { return ApiError::InternalError.into_response(); } diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 6f623b8..611aa2e 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -8,10 +8,10 @@ use axum::{ }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{Duration, Utc}; -use uuid::Uuid; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; +use uuid::Uuid; fn generate_reset_code() -> String { crate::util::generate_token_code() @@ -19,13 +19,15 @@ fn generate_reset_code() -> String { 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(); - } + && 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(); - } + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } "unknown".to_string() } @@ -99,8 +101,7 @@ pub async fn request_password_reset( .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = - crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await + if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await { warn!("Failed to enqueue password reset notification: {:?}", e); } @@ -335,12 +336,11 @@ pub async fn change_password( ) .into_response(); } - let user = sqlx::query_as::<_, (Uuid, String)>( - "SELECT id, password_hash FROM users WHERE did = $1", - ) - .bind(&auth.0.did) - .fetch_optional(&state.db) - .await; + let user = + sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1") + .bind(&auth.0.did) + .fetch_optional(&state.db) + .await; let (user_id, password_hash) = match user { Ok(Some(row)) => row, Ok(None) => { diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs index 7cfda43..c9a4837 100644 --- a/src/api/server/service_auth.rs +++ b/src/api/server/service_auth.rs @@ -55,12 +55,13 @@ pub async fn get_service_auth( Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_user = match crate::auth::validate_bearer_token_for_service_auth(&state.db, &token).await { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; - let key_bytes = match auth_user.key_bytes { - Some(kb) => kb, + let auth_user = + match crate::auth::validate_bearer_token_for_service_auth(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + let key_bytes = match &auth_user.key_bytes { + Some(kb) => kb.clone(), None => { return ApiError::AuthenticationFailedMsg( "OAuth tokens cannot create service auth".into(), @@ -72,6 +73,29 @@ pub async fn get_service_auth( let lxm = params.lxm.as_deref(); let lxm_for_token = lxm.unwrap_or("*"); + if let Some(method) = lxm { + if let Err(e) = crate::auth::scope_check::check_rpc_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + ¶ms.aud, + method, + ) { + return e; + } + } else if auth_user.is_oauth { + let permissions = auth_user.permissions(); + if !permissions.has_full_access() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "OAuth tokens with granular scopes must specify an lxm parameter" + })), + ) + .into_response(); + } + } + let user_status = sqlx::query!( "SELECT takedown_ref FROM users WHERE did = $1", auth_user.did @@ -95,9 +119,10 @@ pub async fn get_service_auth( .into_response(); } - if let Some(method) = lxm { - if PROTECTED_METHODS.contains(&method) { - return ( + if let Some(method) = lxm + && PROTECTED_METHODS.contains(&method) + { + return ( StatusCode::BAD_REQUEST, Json(json!({ "error": "InvalidRequest", @@ -105,7 +130,6 @@ pub async fn get_service_auth( })), ) .into_response(); - } } if let Some(exp) = params.exp { @@ -146,18 +170,22 @@ pub async fn get_service_auth( } } - let service_token = - match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm_for_token, &key_bytes) { - Ok(t) => t, - Err(e) => { - error!("Failed to create service token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let service_token = match crate::auth::create_service_token( + &auth_user.did, + ¶ms.aud, + lxm_for_token, + &key_bytes, + ) { + Ok(t) => t, + Err(e) => { + error!("Failed to create service token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; ( StatusCode::OK, Json(GetServiceAuthOutput { diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 0ca4305..2a1c851 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -16,13 +16,15 @@ use tracing::{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(); - } + && 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(); - } + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } "unknown".to_string() } @@ -36,7 +38,8 @@ fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { } fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { - if stored_handle.contains('.') { + let suffix = format!(".{}", pds_hostname); + if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) { stored_handle.to_string() } else { format!("{}.{}", stored_handle, pds_hostname) @@ -191,6 +194,9 @@ pub async fn get_session( State(state): State, BearerAuthAllowDeactivated(auth_user): BearerAuthAllowDeactivated, ) -> Response { + let permissions = auth_user.permissions(); + let can_read_email = permissions.allows_email_read(); + match sqlx::query!( r#"SELECT handle, email, email_verified, is_admin, deactivated_at, @@ -209,21 +215,29 @@ pub async fn get_session( crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified), crate::comms::CommsChannel::Signal => ("signal", row.signal_verified), }; - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&row.handle, &pds_hostname); let is_active = row.deactivated_at.is_none(); + let email_value = if can_read_email { + row.email.clone() + } else { + None + }; + let email_verified_value = can_read_email && row.email_verified; Json(json!({ "handle": handle, "did": auth_user.did, - "email": row.email, - "emailVerified": row.email_verified, + "email": email_value, + "emailVerified": email_verified_value, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "isAdmin": row.is_admin, "active": is_active, "status": if is_active { "active" } else { "deactivated" }, "didDoc": {} - })).into_response() + })) + .into_response() } Ok(None) => ApiError::AuthenticationFailed.into_response(), Err(e) => { @@ -433,7 +447,8 @@ pub async fn refresh_session( crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified), crate::comms::CommsChannel::Signal => ("signal", u.signal_verified), }; - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&u.handle, &pds_hostname); Json(json!({ "accessJwt": new_access_meta.token, @@ -446,7 +461,8 @@ pub async fn refresh_session( "preferredChannelVerified": preferred_channel_verified, "isAdmin": u.is_admin, "active": true - })).into_response() + })) + .into_response() } Ok(None) => { error!("User not found for existing session: {}", session_row.did); @@ -500,7 +516,8 @@ pub async fn confirm_signup( Ok(Some(row)) => row, Ok(None) => { warn!("User not found for confirm_signup: {}", input.did); - return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response(); + return ApiError::InvalidRequest("Invalid DID or verification code".into()) + .into_response(); } Err(e) => { error!("Database error in confirm_signup: {:?}", e); @@ -532,8 +549,7 @@ pub async fn confirm_signup( } if verification.expires_at < Utc::now() { warn!("Verification code expired for user: {}", input.did); - return ApiError::ExpiredTokenMsg("Verification code has expired".into()) - .into_response(); + return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response(); } let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { @@ -549,10 +565,7 @@ pub async fn confirm_signup( crate::comms::CommsChannel::Telegram => "telegram_verified", crate::comms::CommsChannel::Signal => "signal_verified", }; - let update_query = format!( - "UPDATE users SET {} = TRUE WHERE did = $1", - verified_column - ); + let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); if let Err(e) = sqlx::query(&update_query) .bind(&input.did) .execute(&state.db) @@ -567,7 +580,8 @@ pub async fn confirm_signup( row.id ) .execute(&state.db) - .await { + .await + { error!("Failed to delete verification record: {:?}", e); } @@ -603,10 +617,7 @@ pub async fn confirm_signup( if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { warn!("Failed to enqueue welcome notification: {:?}", e); } - let email_verified = matches!( - row.channel, - crate::comms::CommsChannel::Email - ); + let email_verified = matches!(row.channel, crate::comms::CommsChannel::Email); let preferred_channel = match row.channel { crate::comms::CommsChannel::Email => "email", crate::comms::CommsChannel::Discord => "discord", @@ -688,18 +699,12 @@ pub async fn resend_verification( return ApiError::InternalError.into_response(); } let (channel_str, recipient) = match row.channel { - crate::comms::CommsChannel::Email => { - ("email", row.email.unwrap_or_default()) - } - crate::comms::CommsChannel::Discord => { - ("discord", row.discord_id.unwrap_or_default()) - } + crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()), + crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()), crate::comms::CommsChannel::Telegram => { ("telegram", row.telegram_username.unwrap_or_default()) } - crate::comms::CommsChannel::Signal => { - ("signal", row.signal_number.unwrap_or_default()) - } + crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()), }; if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, @@ -740,7 +745,15 @@ pub async fn list_sessions( .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .and_then(|token| crate::auth::get_jti_from_token(token).ok()); - let result = sqlx::query_as::<_, (i32, String, chrono::DateTime, chrono::DateTime)>( + let result = sqlx::query_as::< + _, + ( + i32, + String, + chrono::DateTime, + chrono::DateTime, + ), + >( r#" SELECT id, access_jti, created_at, refresh_expires_at FROM session_tokens @@ -759,7 +772,7 @@ pub async fn list_sessions( id: id.to_string(), created_at: created_at.to_rfc3339(), expires_at: expires_at.to_rfc3339(), - is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti), + is_current: current_jti.as_ref() == Some(&access_jti), }) .collect(); (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() diff --git a/src/api/temp.rs b/src/api/temp.rs index b089efc..279e182 100644 --- a/src/api/temp.rs +++ b/src/api/temp.rs @@ -6,8 +6,11 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use serde::Serialize; +use cid::Cid; +use jacquard_repo::storage::BlockStore; +use serde::{Deserialize, Serialize}; use serde_json::json; +use std::str::FromStr; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -23,16 +26,17 @@ pub async fn check_signup_queue(State(state): State, headers: HeaderMa if let Some(token) = extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) && let Ok(user) = validate_bearer_token(&state.db, &token).await - && user.is_oauth { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "Forbidden", - "message": "OAuth credentials are not supported for this endpoint" - })), - ) - .into_response(); - } + && user.is_oauth + { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "Forbidden", + "message": "OAuth credentials are not supported for this endpoint" + })), + ) + .into_response(); + } Json(CheckSignupQueueOutput { activated: true, place_in_queue: None, @@ -40,3 +44,111 @@ pub async fn check_signup_queue(State(state): State, headers: HeaderMa }) .into_response() } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DereferenceScopeInput { + pub scope: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DereferenceScopeOutput { + pub scope: String, +} + +pub async fn dereference_scope( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let token = match extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + }; + + if validate_bearer_token(&state.db, &token).await.is_err() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + + let scope_parts: Vec<&str> = input.scope.split_whitespace().collect(); + let mut resolved_scopes: Vec = Vec::new(); + + for part in scope_parts { + if let Some(cid_str) = part.strip_prefix("ref:") { + let cache_key = format!("scope_ref:{}", cid_str); + if let Some(cached) = state.cache.get(&cache_key).await { + for s in cached.split_whitespace() { + if !resolved_scopes.contains(&s.to_string()) { + resolved_scopes.push(s.to_string()); + } + } + continue; + } + + let cid = match Cid::from_str(cid_str) { + Ok(c) => c, + Err(_) => { + tracing::warn!("Invalid CID in scope ref: {}", cid_str); + continue; + } + }; + + let block_bytes = match state.block_store.get(&cid).await { + Ok(Some(b)) => b, + Ok(None) => { + tracing::warn!("Scope ref block not found: {}", cid_str); + continue; + } + Err(e) => { + tracing::warn!("Error fetching scope ref block {}: {:?}", cid_str, e); + continue; + } + }; + + let scope_record: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block_bytes) + { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to decode scope ref block {}: {:?}", cid_str, e); + continue; + } + }; + + if let Some(scope_value) = scope_record.get("scope").and_then(|v| v.as_str()) { + let _ = state + .cache + .set( + &cache_key, + scope_value, + std::time::Duration::from_secs(3600), + ) + .await; + for s in scope_value.split_whitespace() { + if !resolved_scopes.contains(&s.to_string()) { + resolved_scopes.push(s.to_string()); + } + } + } + } else if !resolved_scopes.contains(&part.to_string()) { + resolved_scopes.push(part.to_string()); + } + } + + Json(DereferenceScopeOutput { + scope: resolved_scopes.join(" "), + }) + .into_response() +} diff --git a/src/api/verification.rs b/src/api/verification.rs index 7ed9552..f414e57 100644 --- a/src/api/verification.rs +++ b/src/api/verification.rs @@ -49,11 +49,13 @@ pub async fn confirm_channel_verification( .await { Ok(id) => id, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User not found"})), + ) + .into_response(); + } }; let channel_str = input.channel.as_str(); @@ -88,14 +90,15 @@ pub async fn confirm_channel_verification( .into_response(), }; - let pending_identifier = match record.pending_identifier { - Some(p) => p, - None => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})), - ) - .into_response(), - }; + let pending_identifier = + match record.pending_identifier { + Some(p) => p, + None => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})), + ) + .into_response(), + }; if record.expires_at < Utc::now() { return ( @@ -115,11 +118,13 @@ pub async fn confirm_channel_verification( let mut tx = match state.db.begin().await { Ok(tx) => tx, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(), + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } }; let update_result = match channel_str { @@ -148,7 +153,11 @@ pub async fn confirm_channel_verification( if let Err(e) = update_result { error!("Failed to update user channel: {:?}", e); - if channel_str == "email" && e.as_database_error().map(|db| db.is_unique_violation()).unwrap_or(false) { + if channel_str == "email" + && e.as_database_error() + .map(|db| db.is_unique_violation()) + .unwrap_or(false) + { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "EmailTaken", "message": "Email already in use"})), @@ -168,7 +177,8 @@ pub async fn confirm_channel_verification( channel_str as _ ) .execute(&mut *tx) - .await { + .await + { error!("Failed to delete verification record: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -177,7 +187,7 @@ pub async fn confirm_channel_verification( .into_response(); } - if let Err(_) = tx.commit().await { + if tx.commit().await.is_err() { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 93fb4f8..fb69600 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -83,13 +83,13 @@ impl DidResolver { pub async fn resolve_did(&self, did: &str) -> Option { { let cache = self.did_cache.read().await; - if let Some(cached) = cache.get(did) { - if cached.resolved_at.elapsed() < self.cache_ttl { - return Some(ResolvedService { - url: cached.url.clone(), - did: cached.did.clone(), - }); - } + if let Some(cached) = cache.get(did) + && cached.resolved_at.elapsed() < self.cache_ttl + { + return Some(ResolvedService { + url: cached.url.clone(), + did: cached.did.clone(), + }); } } @@ -240,17 +240,17 @@ impl DidResolver { } } - if let Some(service) = doc.service.first() { - if service.service_endpoint.starts_with("http") { - warn!( - "No explicit AppView service found for {}, using first service: {}", - doc.id, service.service_endpoint - ); - return Some(ResolvedService { - url: service.service_endpoint.clone(), - did: doc.id.clone(), - }); - } + if let Some(service) = doc.service.first() + && service.service_endpoint.starts_with("http") + { + warn!( + "No explicit AppView service found for {}, using first service: {}", + doc.id, service.service_endpoint + ); + return Some(ResolvedService { + url: service.service_endpoint.clone(), + did: doc.id.clone(), + }); } if doc.id.starts_with("did:web:") { diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 52a2b2e..43bdaff 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::{ AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, - validate_bearer_token_cached_allow_deactivated, + validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop, }; use crate::state::AppState; @@ -63,6 +63,7 @@ impl IntoResponse for AuthError { } } +#[cfg(test)] fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> { let auth_header = auth_header.trim(); @@ -151,13 +152,37 @@ impl FromRequestParts for BearerAuth { .to_str() .map_err(|_| AuthError::InvalidFormat)?; - let token = extract_bearer_token(auth_header)?; + let extracted = + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - match validate_bearer_token_cached(&state.db, &state.cache, token).await { - Ok(user) => Ok(BearerAuth(user)), - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(_) => Err(AuthError::AuthenticationFailed), + if extracted.is_dpop { + let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = parts.uri.to_string(); + + match validate_token_with_dpop( + &state.db, + &extracted.token, + true, + dpop_proof, + method, + &uri, + false, + ) + .await + { + Ok(user) => Ok(BearerAuth(user)), + Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } + } else { + match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await { + Ok(user) => Ok(BearerAuth(user)), + Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } } } } @@ -178,12 +203,41 @@ impl FromRequestParts for BearerAuthAllowDeactivated { .to_str() .map_err(|_| AuthError::InvalidFormat)?; - let token = extract_bearer_token(auth_header)?; + let extracted = + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await { - Ok(user) => Ok(BearerAuthAllowDeactivated(user)), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(_) => Err(AuthError::AuthenticationFailed), + if extracted.is_dpop { + let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = parts.uri.to_string(); + + match validate_token_with_dpop( + &state.db, + &extracted.token, + true, + dpop_proof, + method, + &uri, + true, + ) + .await + { + Ok(user) => Ok(BearerAuthAllowDeactivated(user)), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } + } else { + match validate_bearer_token_cached_allow_deactivated( + &state.db, + &state.cache, + &extracted.token, + ) + .await + { + Ok(user) => Ok(BearerAuthAllowDeactivated(user)), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } } } } @@ -204,19 +258,51 @@ impl FromRequestParts for BearerAuthAdmin { .to_str() .map_err(|_| AuthError::InvalidFormat)?; - let token = extract_bearer_token(auth_header)?; + let extracted = + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - match validate_bearer_token_cached(&state.db, &state.cache, token).await { - Ok(user) => { - if !user.is_admin { - return Err(AuthError::AdminRequired); + let user = if extracted.is_dpop { + let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = parts.uri.to_string(); + + match validate_token_with_dpop( + &state.db, + &extracted.token, + true, + dpop_proof, + method, + &uri, + false, + ) + .await + { + Ok(user) => user, + Err(TokenValidationError::AccountDeactivated) => { + return Err(AuthError::AccountDeactivated); } - Ok(BearerAuthAdmin(user)) + Err(TokenValidationError::AccountTakedown) => { + return Err(AuthError::AccountTakedown); + } + Err(_) => return Err(AuthError::AuthenticationFailed), } - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(_) => Err(AuthError::AuthenticationFailed), + } else { + match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await { + Ok(user) => user, + Err(TokenValidationError::AccountDeactivated) => { + return Err(AuthError::AccountDeactivated); + } + Err(TokenValidationError::AccountTakedown) => { + return Err(AuthError::AccountTakedown); + } + Err(_) => return Err(AuthError::AuthenticationFailed), + } + }; + + if !user.is_admin { + return Err(AuthError::AdminRequired); } + Ok(BearerAuthAdmin(user)) } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index b9a1f82..359b842 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -5,8 +5,10 @@ use std::sync::Arc; use std::time::Duration; use crate::cache::Cache; +use crate::oauth::scopes::ScopePermissions; pub mod extractor; +pub mod scope_check; pub mod service; pub mod token; pub mod verify; @@ -15,6 +17,7 @@ pub use extractor::{ AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken, extract_auth_token_from_header, extract_bearer_token_from_header, }; +pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; pub use token::{ SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token, @@ -24,7 +27,6 @@ pub use token::{ pub use verify::{ get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, verify_token, }; -pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; const KEY_CACHE_TTL_SECS: u64 = 300; const SESSION_CACHE_TTL_SECS: u64 = 60; @@ -53,6 +55,16 @@ pub struct AuthenticatedUser { pub key_bytes: Option>, pub is_oauth: bool, pub is_admin: bool, + pub scope: Option, +} + +impl AuthenticatedUser { + pub fn permissions(&self) -> ScopePermissions { + if !self.is_oauth { + return ScopePermissions::from_scope_string(Some("atproto")); + } + ScopePermissions::from_scope_string(self.scope.as_deref()) + } } pub async fn validate_bearer_token( @@ -114,7 +126,8 @@ async fn validate_bearer_token_with_options_internal( } } - let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key { + let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key + { let user_status = sqlx::query!( "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", did @@ -125,7 +138,12 @@ async fn validate_bearer_token_with_options_internal( .flatten(); match user_status { - Some(status) => (Some(key), status.deactivated_at, status.takedown_ref, status.is_admin), + Some(status) => ( + Some(key), + status.deactivated_at, + status.takedown_ref, + status.is_admin, + ), None => (None, None, None, false), } } else if let Some(user) = sqlx::query!( @@ -153,7 +171,12 @@ async fn validate_bearer_token_with_options_internal( .await; } - (Some(key), user.deactivated_at, user.takedown_ref, user.is_admin) + ( + Some(key), + user.deactivated_at, + user.takedown_ref, + user.is_admin, + ) } else { (None, None, None, false) }; @@ -194,16 +217,15 @@ async fn validate_bearer_token_with_options_internal( session_valid = session_exists.is_some(); - if session_valid - && let Some(c) = cache { - let _ = c - .set( - &session_cache_key, - "1", - Duration::from_secs(SESSION_CACHE_TTL_SECS), - ) - .await; - } + if session_valid && let Some(c) = cache { + let _ = c + .set( + &session_cache_key, + "1", + Duration::from_secs(SESSION_CACHE_TTL_SECS), + ) + .await; + } } if session_valid { @@ -212,6 +234,7 @@ async fn validate_bearer_token_with_options_internal( key_bytes: Some(decrypted_key), is_oauth: false, is_admin, + scope: None, }); } } @@ -232,33 +255,34 @@ async fn validate_bearer_token_with_options_internal( .await .ok() .flatten() - { - if !allow_deactivated && oauth_token.deactivated_at.is_some() { - return Err(TokenValidationError::AccountDeactivated); - } - - if oauth_token.takedown_ref.is_some() { - return Err(TokenValidationError::AccountTakedown); - } - - let now = chrono::Utc::now(); - if oauth_token.expires_at > now { - let key_bytes = if let (Some(kb), Some(ev)) = - (&oauth_token.key_bytes, oauth_token.encryption_version) - { - crate::config::decrypt_key(kb, Some(ev)).ok() - } else { - None - }; - return Ok(AuthenticatedUser { - did: oauth_token.did, - key_bytes, - is_oauth: true, - is_admin: oauth_token.is_admin, - }); - } + { + if !allow_deactivated && oauth_token.deactivated_at.is_some() { + return Err(TokenValidationError::AccountDeactivated); } + if oauth_token.takedown_ref.is_some() { + return Err(TokenValidationError::AccountTakedown); + } + + let now = chrono::Utc::now(); + if oauth_token.expires_at > now { + let key_bytes = if let (Some(kb), Some(ev)) = + (&oauth_token.key_bytes, oauth_token.encryption_version) + { + crate::config::decrypt_key(kb, Some(ev)).ok() + } else { + None + }; + return Ok(AuthenticatedUser { + did: oauth_token.did, + key_bytes, + is_oauth: true, + is_admin: oauth_token.is_admin, + scope: oauth_info.scope, + }); + } + } + Err(TokenValidationError::AuthenticationFailed) } @@ -314,7 +338,9 @@ pub async fn validate_token_with_dpop( if user_info.takedown_ref.is_some() { return Err(TokenValidationError::AccountTakedown); } - let key_bytes = if let (Some(kb), Some(ev)) = (&user_info.key_bytes, user_info.encryption_version) { + let key_bytes = if let (Some(kb), Some(ev)) = + (&user_info.key_bytes, user_info.encryption_version) + { crate::config::decrypt_key(kb, Some(ev)).ok() } else { None @@ -324,6 +350,7 @@ pub async fn validate_token_with_dpop( key_bytes, is_oauth: true, is_admin: user_info.is_admin, + scope: result.scope, }) } Err(_) => Err(TokenValidationError::AuthenticationFailed), diff --git a/src/auth/scope_check.rs b/src/auth/scope_check.rs new file mode 100644 index 0000000..275a99d --- /dev/null +++ b/src/auth/scope_check.rs @@ -0,0 +1,118 @@ +#![allow(clippy::result_large_err)] + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::oauth::scopes::{ + AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, +}; + +pub fn check_repo_scope( + is_oauth: bool, + scope: Option<&str>, + action: RepoAction, + collection: &str, +) -> Result<(), Response> { + if !is_oauth { + return Ok(()); + } + + let permissions = ScopePermissions::from_scope_string(scope); + permissions.assert_repo(action, collection).map_err(|e| { + ( + StatusCode::FORBIDDEN, + axum::Json(json!({ + "error": "InsufficientScope", + "message": e.to_string() + })), + ) + .into_response() + }) +} + +pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { + if !is_oauth { + return Ok(()); + } + + let permissions = ScopePermissions::from_scope_string(scope); + permissions.assert_blob(mime).map_err(|e| { + ( + StatusCode::FORBIDDEN, + axum::Json(json!({ + "error": "InsufficientScope", + "message": e.to_string() + })), + ) + .into_response() + }) +} + +pub fn check_rpc_scope( + is_oauth: bool, + scope: Option<&str>, + aud: &str, + lxm: &str, +) -> Result<(), Response> { + if !is_oauth { + return Ok(()); + } + + let permissions = ScopePermissions::from_scope_string(scope); + permissions.assert_rpc(aud, lxm).map_err(|e| { + ( + StatusCode::FORBIDDEN, + axum::Json(json!({ + "error": "InsufficientScope", + "message": e.to_string() + })), + ) + .into_response() + }) +} + +pub fn check_account_scope( + is_oauth: bool, + scope: Option<&str>, + attr: AccountAttr, + action: AccountAction, +) -> Result<(), Response> { + if !is_oauth { + return Ok(()); + } + + let permissions = ScopePermissions::from_scope_string(scope); + permissions.assert_account(attr, action).map_err(|e| { + ( + StatusCode::FORBIDDEN, + axum::Json(json!({ + "error": "InsufficientScope", + "message": e.to_string() + })), + ) + .into_response() + }) +} + +pub fn check_identity_scope( + is_oauth: bool, + scope: Option<&str>, + attr: IdentityAttr, +) -> Result<(), Response> { + if !is_oauth { + return Ok(()); + } + + let permissions = ScopePermissions::from_scope_string(scope); + permissions.assert_identity(attr).map_err(|e| { + ( + StatusCode::FORBIDDEN, + axum::Json(json!({ + "error": "InsufficientScope", + "message": e.to_string() + })), + ) + .into_response() + }) +} diff --git a/src/auth/service.rs b/src/auth/service.rs index a912695..1b52020 100644 --- a/src/auth/service.rs +++ b/src/auth/service.rs @@ -278,11 +278,13 @@ impl Default for ServiceTokenVerifier { fn parse_did_key_multibase(multibase: &str) -> Result { if !multibase.starts_with('z') { - return Err(anyhow!("Expected base58btc multibase encoding (starts with 'z')")); + return Err(anyhow!( + "Expected base58btc multibase encoding (starts with 'z')" + )); } - let (_, decoded) = multibase::decode(multibase) - .map_err(|e| anyhow!("Failed to decode multibase: {}", e))?; + let (_, decoded) = + multibase::decode(multibase).map_err(|e| anyhow!("Failed to decode multibase: {}", e))?; if decoded.len() < 2 { return Err(anyhow!("Invalid multicodec data")); @@ -302,8 +304,7 @@ fn parse_did_key_multibase(multibase: &str) -> Result { return Err(anyhow!("Only secp256k1 keys are supported")); } - VerifyingKey::from_sec1_bytes(key_bytes) - .map_err(|e| anyhow!("Invalid public key: {}", e)) + VerifyingKey::from_sec1_bytes(key_bytes).map_err(|e| anyhow!("Invalid public key: {}", e)) } pub fn is_service_token(token: &str) -> bool { diff --git a/src/auth/verify.rs b/src/auth/verify.rs index 670ef99..a38f810 100644 --- a/src/auth/verify.rs +++ b/src/auth/verify.rs @@ -113,13 +113,14 @@ fn verify_token_internal( serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?; if let Some(expected) = expected_typ - && header.typ != expected { - return Err(anyhow!( - "Invalid token type: expected {}, got {}", - expected, - header.typ - )); - } + && header.typ != expected + { + return Err(anyhow!( + "Invalid token type: expected {}, got {}", + expected, + header.typ + )); + } let signature_bytes = URL_SAFE_NO_PAD .decode(signature_b64) @@ -185,13 +186,14 @@ fn verify_token_hs256_internal( } if let Some(expected) = expected_typ - && header.typ != expected { - return Err(anyhow!( - "Invalid token type: expected {}, got {}", - expected, - header.typ - )); - } + && header.typ != expected + { + return Err(anyhow!( + "Invalid token type: expected {}, got {}", + expected, + header.typ + )); + } let signature_bytes = URL_SAFE_NO_PAD .decode(signature_b64) diff --git a/src/comms/mod.rs b/src/comms/mod.rs index c14c4be..cf39102 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -8,8 +8,8 @@ pub use sender::{ }; pub use service::{ - CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, - enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset, + CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, + enqueue_email_update, enqueue_email_verification, enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, }; diff --git a/src/comms/sender.rs b/src/comms/sender.rs index 02286a9..236b3db 100644 --- a/src/comms/sender.rs +++ b/src/comms/sender.rs @@ -87,7 +87,8 @@ impl EmailSender { pub fn from_env() -> Option { let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?; - let from_name = std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string()); + let from_name = + std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string()); Some(Self::new(from_address, from_name)) } diff --git a/src/comms/service.rs b/src/comms/service.rs index 3a75585..099171b 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use super::sender::{CommsSender, SendError}; -use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms}; +use super::types::{CommsChannel, CommsStatus, NewComms, QueuedComms}; pub struct CommsService { db: PgPool, diff --git a/src/config.rs b/src/config.rs index 7bf915f..b1998f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,11 +46,15 @@ impl AuthConfig { } }); - if jwt_secret.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { + if jwt_secret.len() < 32 + && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() + { panic!("JWT_SECRET must be at least 32 characters"); } - if dpop_secret.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { + if dpop_secret.len() < 32 + && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() + { panic!("DPOP_SECRET must be at least 32 characters"); } @@ -97,7 +101,9 @@ impl AuthConfig { } }); - if master_key.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() { + if master_key.len() < 32 + && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() + { panic!("MASTER_KEY must be at least 32 characters"); } diff --git a/src/crawlers.rs b/src/crawlers.rs index cc58b8c..9e664f9 100644 --- a/src/crawlers.rs +++ b/src/crawlers.rs @@ -79,10 +79,11 @@ impl Crawlers { } if let Some(cb) = &self.circuit_breaker - && !cb.can_execute().await { - debug!("Skipping crawler notification due to circuit breaker open"); - return; - } + && !cb.can_execute().await + { + debug!("Skipping crawler notification due to circuit breaker open"); + return; + } self.mark_notified(); let circuit_breaker = self.circuit_breaker.clone(); diff --git a/src/handle/mod.rs b/src/handle/mod.rs index 31cb09a..a1c90a0 100644 --- a/src/handle/mod.rs +++ b/src/handle/mod.rs @@ -1,5 +1,5 @@ -use hickory_resolver::config::{ResolverConfig, ResolverOpts}; use hickory_resolver::TokioAsyncResolver; +use hickory_resolver::config::{ResolverConfig, ResolverOpts}; use reqwest::Client; use std::time::Duration; use thiserror::Error; diff --git a/src/lib.rs b/src/lib.rs index eafad2f..2a33529 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,12 +3,12 @@ pub mod appview; pub mod auth; pub mod cache; pub mod circuit_breaker; +pub mod comms; pub mod config; pub mod crawlers; pub mod handle; pub mod image; pub mod metrics; -pub mod comms; pub mod oauth; pub mod plc; pub mod rate_limit; @@ -343,6 +343,10 @@ pub fn app(state: AppState) -> Router { ) .route("/oauth/authorize", get(oauth::endpoints::authorize_get)) .route("/oauth/authorize", post(oauth::endpoints::authorize_post)) + .route( + "/oauth/authorize/accounts", + get(oauth::endpoints::authorize_accounts), + ) .route( "/oauth/authorize/select", post(oauth::endpoints::authorize_select), @@ -359,6 +363,14 @@ pub fn app(state: AppState) -> Router { "/oauth/authorize/deny", post(oauth::endpoints::authorize_deny), ) + .route( + "/oauth/authorize/consent", + get(oauth::endpoints::consent_get), + ) + .route( + "/oauth/authorize/consent", + post(oauth::endpoints::consent_post), + ) .route("/oauth/token", post(oauth::endpoints::token_endpoint)) .route("/oauth/revoke", post(oauth::endpoints::revoke_token)) .route( @@ -369,6 +381,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.temp.checkSignupQueue", get(api::temp::check_signup_queue), ) + .route( + "/xrpc/com.atproto.temp.dereferenceScope", + post(api::temp::dereference_scope), + ) .route( "/xrpc/com.tranquil.account.getNotificationPrefs", get(api::notification_prefs::get_notification_prefs), diff --git a/src/main.rs b/src/main.rs index bf46381..b895c4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; -use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; -use tranquil_pds::state::AppState; use std::net::SocketAddr; use std::process::ExitCode; use std::sync::Arc; use tokio::sync::watch; use tracing::{error, info, warn}; +use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; +use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; +use tranquil_pds::state::AppState; #[tokio::main] async fn main() -> ExitCode { diff --git a/src/metrics.rs b/src/metrics.rs index b09787e..f7c0181 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -24,7 +24,10 @@ pub fn init_metrics() -> PrometheusHandle { } fn describe_metrics() { - metrics::describe_counter!("tranquil_pds_http_requests_total", "Total number of HTTP requests"); + metrics::describe_counter!( + "tranquil_pds_http_requests_total", + "Total number of HTTP requests" + ); metrics::describe_histogram!( "tranquil_pds_http_request_duration_seconds", "HTTP request duration in seconds" @@ -61,7 +64,10 @@ fn describe_metrics() { "tranquil_pds_rate_limit_rejections_total", "Total number of rate limit rejections" ); - metrics::describe_counter!("tranquil_pds_db_queries_total", "Total number of database queries"); + metrics::describe_counter!( + "tranquil_pds_db_queries_total", + "Total number of database queries" + ); metrics::describe_histogram!( "tranquil_pds_db_query_duration_seconds", "Database query duration in seconds" @@ -116,12 +122,13 @@ pub async fn metrics_middleware(request: Request, next: Next) -> Response fn normalize_path(path: &str) -> String { if path.starts_with("/xrpc/") - && let Some(method) = path.strip_prefix("/xrpc/") { - if let Some(q) = method.find('?') { - return format!("/xrpc/{}", &method[..q]); - } - return path.to_string(); + && let Some(method) = path.strip_prefix("/xrpc/") + { + if let Some(q) = method.find('?') { + return format!("/xrpc/{}", &method[..q]); } + return path.to_string(); + } if path.starts_with("/u/") && path.ends_with("/did.json") { return "/u/{handle}/did.json".to_string(); @@ -135,11 +142,13 @@ fn normalize_path(path: &str) -> String { } pub fn record_auth_cache_hit(cache_type: &str) { - counter!("tranquil_pds_auth_cache_hits_total", "cache_type" => cache_type.to_string()).increment(1); + counter!("tranquil_pds_auth_cache_hits_total", "cache_type" => cache_type.to_string()) + .increment(1); } pub fn record_auth_cache_miss(cache_type: &str) { - counter!("tranquil_pds_auth_cache_misses_total", "cache_type" => cache_type.to_string()).increment(1); + counter!("tranquil_pds_auth_cache_misses_total", "cache_type" => cache_type.to_string()) + .increment(1); } pub fn set_firehose_subscribers(count: usize) { @@ -172,7 +181,8 @@ pub fn set_comms_queue_size(size: usize) { } pub fn record_rate_limit_rejection(limiter: &str) { - counter!("tranquil_pds_rate_limit_rejections_total", "limiter" => limiter.to_string()).increment(1); + counter!("tranquil_pds_rate_limit_rejections_total", "limiter" => limiter.to_string()) + .increment(1); } pub fn record_db_query(query_type: &str, duration_seconds: f64) { diff --git a/src/oauth/client.rs b/src/oauth/client.rs index ceea205..df4771b 100644 --- a/src/oauth/client.rs +++ b/src/oauth/client.rs @@ -135,9 +135,10 @@ impl ClientMetadataCache { { let cache = self.cache.read().await; if let Some(cached) = cache.get(client_id) - && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { - return Ok(cached.metadata.clone()); - } + && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs + { + return Ok(cached.metadata.clone()); + } } let metadata = self.fetch_metadata(client_id).await?; { @@ -168,9 +169,10 @@ impl ClientMetadataCache { { let cache = self.jwks_cache.read().await; if let Some(cached) = cache.get(jwks_uri) - && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { - return Ok(cached.jwks.clone()); - } + && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs + { + return Ok(cached.jwks.clone()); + } } let jwks = self.fetch_jwks(jwks_uri).await?; { @@ -190,11 +192,11 @@ impl ClientMetadataCache { if !jwks_uri.starts_with("https://") && (!jwks_uri.starts_with("http://") || (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1"))) - { - return Err(OAuthError::InvalidClient( - "jwks_uri must use https (except for localhost)".to_string(), - )); - } + { + return Err(OAuthError::InvalidClient( + "jwks_uri must use https (except for localhost)".to_string(), + )); + } let response = self .http_client .get(jwks_uri) @@ -302,26 +304,27 @@ impl ClientMetadataCache { return Ok(()); } if Self::is_loopback_client(&metadata.client_id) - && let Ok(req_url) = reqwest::Url::parse(redirect_uri) { - let req_host = req_url.host_str().unwrap_or(""); - let is_loopback_redirect = req_url.scheme() == "http" - && (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]"); - if is_loopback_redirect { - for registered in &metadata.redirect_uris { - if let Ok(reg_url) = reqwest::Url::parse(registered) { - let reg_host = reg_url.host_str().unwrap_or(""); - let hosts_match = (req_host == "localhost" && reg_host == "localhost") - || (req_host == "127.0.0.1" && reg_host == "127.0.0.1") - || (req_host == "[::1]" && reg_host == "[::1]") - || (req_host == "localhost" && reg_host == "127.0.0.1") - || (req_host == "127.0.0.1" && reg_host == "localhost"); - if hosts_match && req_url.path() == reg_url.path() { - return Ok(()); - } + && let Ok(req_url) = reqwest::Url::parse(redirect_uri) + { + let req_host = req_url.host_str().unwrap_or(""); + let is_loopback_redirect = req_url.scheme() == "http" + && (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]"); + if is_loopback_redirect { + for registered in &metadata.redirect_uris { + if let Ok(reg_url) = reqwest::Url::parse(registered) { + let reg_host = reg_url.host_str().unwrap_or(""); + let hosts_match = (req_host == "localhost" && reg_host == "localhost") + || (req_host == "127.0.0.1" && reg_host == "127.0.0.1") + || (req_host == "[::1]" && reg_host == "[::1]") + || (req_host == "localhost" && reg_host == "127.0.0.1") + || (req_host == "127.0.0.1" && reg_host == "localhost"); + if hosts_match && req_url.path() == reg_url.path() { + return Ok(()); } } } } + } Err(OAuthError::InvalidRequest( "redirect_uri not registered for client".to_string(), )) @@ -501,11 +504,12 @@ async fn verify_private_key_jwt_async( )); } if let Some(iat) = iat - && iat > now + 60 { - return Err(OAuthError::InvalidClient( - "client_assertion iat is in the future".to_string(), - )); - } + && iat > now + 60 + { + return Err(OAuthError::InvalidClient( + "client_assertion iat is in the future".to_string(), + )); + } let jwks = cache.get_jwks(metadata).await?; let keys = jwks .get("keys") diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs index 9e3bf47..14bdc31 100644 --- a/src/oauth/db/mod.rs +++ b/src/oauth/db/mod.rs @@ -3,6 +3,7 @@ mod device; mod dpop; mod helpers; mod request; +mod scope_preference; mod token; mod two_factor; @@ -15,12 +16,17 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis}; pub use request::{ consume_authorization_request_by_code, create_authorization_request, delete_authorization_request, delete_expired_authorization_requests, get_authorization_request, - update_authorization_request, + mark_request_authenticated, set_authorization_did, update_authorization_request, + update_request_scope, +}; +pub use scope_preference::{ + ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent, + upsert_scope_preferences, }; pub use token::{ check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, - get_token_by_refresh_token, list_tokens_for_user, rotate_token, + get_token_by_refresh_token, list_tokens_for_user, revoke_tokens_for_client, rotate_token, }; pub use two_factor::{ TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges, diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs index d1d01cf..aec70e4 100644 --- a/src/oauth/db/request.rs +++ b/src/oauth/db/request.rs @@ -67,6 +67,27 @@ pub async fn get_authorization_request( } } +pub async fn set_authorization_did( + pool: &PgPool, + request_id: &str, + did: &str, + device_id: Option<&str>, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3 + WHERE id = $1 + "#, + request_id, + did, + device_id + ) + .execute(pool) + .await?; + Ok(()) +} + pub async fn update_authorization_request( pool: &PgPool, request_id: &str, @@ -151,3 +172,43 @@ pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3 + WHERE id = $1 + "#, + request_id, + did, + device_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update_request_scope( + pool: &PgPool, + request_id: &str, + scope: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET parameters = jsonb_set(parameters, '{scope}', to_jsonb($2::text)) + WHERE id = $1 + "#, + request_id, + scope + ) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/oauth/db/scope_preference.rs b/src/oauth/db/scope_preference.rs new file mode 100644 index 0000000..b5459f6 --- /dev/null +++ b/src/oauth/db/scope_preference.rs @@ -0,0 +1,103 @@ +use super::super::OAuthError; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopePreference { + pub scope: String, + pub granted: bool, +} + +pub async fn get_scope_preferences( + pool: &PgPool, + did: &str, + client_id: &str, +) -> Result, OAuthError> { + let rows = sqlx::query!( + r#" + SELECT scope, granted FROM oauth_scope_preference + WHERE did = $1 AND client_id = $2 + "#, + did, + client_id + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| ScopePreference { + scope: r.scope, + granted: r.granted, + }) + .collect()) +} + +pub async fn upsert_scope_preferences( + pool: &PgPool, + did: &str, + client_id: &str, + prefs: &[ScopePreference], +) -> Result<(), OAuthError> { + for pref in prefs { + sqlx::query!( + r#" + INSERT INTO oauth_scope_preference (did, client_id, scope, granted, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (did, client_id, scope) DO UPDATE SET granted = $4, updated_at = NOW() + "#, + did, + client_id, + pref.scope, + pref.granted + ) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn should_show_consent( + pool: &PgPool, + did: &str, + client_id: &str, + requested_scopes: &[String], +) -> Result { + if requested_scopes.is_empty() { + return Ok(false); + } + + let stored_prefs = get_scope_preferences(pool, did, client_id).await?; + if stored_prefs.is_empty() { + return Ok(true); + } + + let stored_scopes: std::collections::HashSet<&str> = + stored_prefs.iter().map(|p| p.scope.as_str()).collect(); + + for scope in requested_scopes { + if !stored_scopes.contains(scope.as_str()) { + return Ok(true); + } + } + + Ok(false) +} + +pub async fn delete_scope_preferences( + pool: &PgPool, + did: &str, + client_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_scope_preference + WHERE did = $1 AND client_id = $2 + "#, + did, + client_id + ) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs index ba8d67a..f855b25 100644 --- a/src/oauth/db/token.rs +++ b/src/oauth/db/token.rs @@ -268,3 +268,18 @@ pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<() } Ok(()) } + +pub async fn revoke_tokens_for_client( + pool: &PgPool, + did: &str, + client_id: &str, +) -> Result { + let result = sqlx::query!( + "DELETE FROM oauth_token WHERE did = $1 AND client_id = $2", + did, + client_id + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 60d0698..f21c860 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -1,16 +1,16 @@ use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code}; use crate::oauth::{ - Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates, + Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, }; use crate::state::{AppState, RateLimitKind}; use axum::{ - Form, Json, + Json, extract::{Query, State}, http::{ HeaderMap, StatusCode, header::{LOCATION, SET_COOKIE}, }, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Response}, }; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -23,6 +23,14 @@ fn redirect_see_other(uri: &str) -> Response { (StatusCode::SEE_OTHER, [(LOCATION, uri.to_string())]).into_response() } +fn redirect_to_frontend_error(error: &str, description: &str) -> Response { + redirect_see_other(&format!( + "/#/oauth/error?error={}&error_description={}", + url_encode(error), + url_encode(description) + )) +} + fn extract_device_cookie(headers: &HeaderMap) -> Option { headers .get("cookie") @@ -41,13 +49,15 @@ 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(); - } + && 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(); - } + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } "0.0.0.0".to_string() } @@ -115,21 +125,17 @@ pub async fn authorize_get( None => { if wants_json(&headers) { return ( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "invalid_request", "error_description": "Missing request_uri parameter. Use PAR to initiate authorization." })), ).into_response(); } - return ( - axum::http::StatusCode::BAD_REQUEST, - Html(templates::error_page( - "invalid_request", - Some("Missing request_uri parameter. Use PAR to initiate authorization."), - )), - ) - .into_response(); + return redirect_to_frontend_error( + "invalid_request", + "Missing request_uri parameter. Use PAR to initiate authorization.", + ); } }; let request_data = match db::get_authorization_request(&state.db, &request_uri).await { @@ -137,28 +143,22 @@ pub async fn authorize_get( Ok(None) => { if wants_json(&headers) { return ( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "invalid_request", "error_description": "Invalid or expired request_uri. Please start a new authorization request." })), ).into_response(); } - return ( - axum::http::StatusCode::BAD_REQUEST, - Html(templates::error_page( - "invalid_request", - Some( - "Invalid or expired request_uri. Please start a new authorization request.", - ), - )), - ) - .into_response(); + return redirect_to_frontend_error( + "invalid_request", + "Invalid or expired request_uri. Please start a new authorization request.", + ); } Err(e) => { if wants_json(&headers) { return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "server_error", "error_description": format!("Database error: {:?}", e) @@ -166,35 +166,24 @@ pub async fn authorize_get( ) .into_response(); } - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - Html(templates::error_page( - "server_error", - Some(&format!("Database error: {:?}", e)), - )), - ) - .into_response(); + return redirect_to_frontend_error("server_error", "A database error occurred."); } }; if request_data.expires_at < Utc::now() { let _ = db::delete_authorization_request(&state.db, &request_uri).await; if wants_json(&headers) { return ( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "invalid_request", "error_description": "Authorization request has expired. Please start a new request." })), ).into_response(); } - return ( - axum::http::StatusCode::BAD_REQUEST, - Html(templates::error_page( - "invalid_request", - Some("Authorization request has expired. Please start a new request."), - )), - ) - .into_response(); + return redirect_to_frontend_error( + "invalid_request", + "Authorization request has expired. Please start a new request.", + ); } let client_cache = ClientMetadataCache::new(3600); let client_name = client_cache @@ -216,34 +205,18 @@ pub async fn authorize_get( let force_new_account = query.new_account.unwrap_or(false); if !force_new_account && let Some(device_id) = extract_device_cookie(&headers) - && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await - && !accounts.is_empty() { - let device_accounts: Vec = accounts - .into_iter() - .map(|row| DeviceAccount { - did: row.did, - handle: row.handle, - email: row.email, - last_used_at: row.last_used_at, - }) - .collect(); - return Html(templates::account_selector_page( - &request_data.parameters.client_id, - client_name.as_deref(), - &request_uri, - &device_accounts, - )) - .into_response(); - } - Html(templates::login_page( - &request_data.parameters.client_id, - client_name.as_deref(), - request_data.parameters.scope.as_deref(), - &request_uri, - None, - request_data.parameters.login_hint.as_deref(), + && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await + && !accounts.is_empty() + { + return redirect_see_other(&format!( + "/#/oauth/accounts?request_uri={}", + url_encode(&request_uri) + )); + } + redirect_see_other(&format!( + "/#/oauth/login?request_uri={}", + url_encode(&request_uri) )) - .into_response() } pub async fn authorize_get_json( @@ -272,10 +245,93 @@ pub async fn authorize_get_json( })) } +#[derive(Debug, Serialize)] +pub struct AccountInfo { + pub did: String, + pub handle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +#[derive(Debug, Serialize)] +pub struct AccountsResponse { + pub accounts: Vec, + pub request_uri: String, +} + +fn mask_email(email: &str) -> String { + if let Some(at_pos) = email.find('@') { + let local = &email[..at_pos]; + let domain = &email[at_pos..]; + if local.len() <= 2 { + format!("{}***{}", local.chars().next().unwrap_or('*'), domain) + } else { + let first = local.chars().next().unwrap_or('*'); + let last = local.chars().last().unwrap_or('*'); + format!("{}***{}{}", first, last, domain) + } + } else { + "***".to_string() + } +} + +pub async fn authorize_accounts( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let request_uri = match query.request_uri { + Some(uri) => uri, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Missing request_uri parameter" + })), + ) + .into_response(); + } + }; + let device_id = match extract_device_cookie(&headers) { + Some(id) => id, + None => { + return Json(AccountsResponse { + accounts: vec![], + request_uri, + }) + .into_response(); + } + }; + let accounts = match db::get_device_accounts(&state.db, &device_id).await { + Ok(accts) => accts, + Err(_) => { + return Json(AccountsResponse { + accounts: vec![], + request_uri, + }) + .into_response(); + } + }; + let account_infos: Vec = accounts + .into_iter() + .map(|row| AccountInfo { + did: row.did, + handle: row.handle, + email: row.email.map(|e| mask_email(&e)), + }) + .collect(); + Json(AccountsResponse { + accounts: account_infos, + request_uri, + }) + .into_response() +} + pub async fn authorize_post( State(state): State, headers: HeaderMap, - Form(form): Form, + Json(form): Json, ) -> Response { let json_response = wants_json(&headers); let client_ip = extract_client_ip(&headers); @@ -294,14 +350,10 @@ pub async fn authorize_post( ) .into_response(); } - return ( - axum::http::StatusCode::TOO_MANY_REQUESTS, - Html(templates::error_page( - "RateLimitExceeded", - Some("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 request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { Ok(Some(data)) => data, @@ -316,11 +368,10 @@ pub async fn authorize_post( ) .into_response(); } - return Html(templates::error_page( + return redirect_to_frontend_error( "invalid_request", - Some("Invalid or expired request_uri. Please start a new authorization request."), - )) - .into_response(); + "Invalid or expired request_uri. Please start a new authorization request.", + ); } Err(e) => { if json_response { @@ -333,11 +384,7 @@ pub async fn authorize_post( ) .into_response(); } - return Html(templates::error_page( - "server_error", - Some(&format!("Database error: {:?}", e)), - )) - .into_response(); + return redirect_to_frontend_error("server_error", &format!("Database error: {:?}", e)); } }; if request_data.expires_at < Utc::now() { @@ -352,18 +399,11 @@ pub async fn authorize_post( ) .into_response(); } - return Html(templates::error_page( + return redirect_to_frontend_error( "invalid_request", - Some("Authorization request has expired. Please start a new request."), - )) - .into_response(); + "Authorization request has expired. Please start a new request.", + ); } - let client_cache = ClientMetadataCache::new(3600); - let client_name = client_cache - .get(&request_data.parameters.client_id) - .await - .ok() - .and_then(|m| m.client_name); let show_login_error = |error_msg: &str, json: bool| -> Response { if json { return ( @@ -375,15 +415,11 @@ pub async fn authorize_post( ) .into_response(); } - Html(templates::login_page( - &request_data.parameters.client_id, - client_name.as_deref(), - request_data.parameters.scope.as_deref(), - &form.request_uri, - Some(error_msg), - Some(&form.username), + redirect_see_other(&format!( + "/#/oauth/login?request_uri={}&error={}", + url_encode(&form.request_uri), + url_encode(error_msg) )) - .into_response() }; let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let normalized_username = form.username.trim(); @@ -419,7 +455,10 @@ pub async fn authorize_post( { Ok(Some(u)) => u, Ok(None) => { - let _ = bcrypt::verify(&form.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK"); + let _ = bcrypt::verify( + &form.password, + "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", + ); return show_login_error("Invalid handle/email or password.", json_response); } Err(_) => return show_login_error("An error occurred. Please try again.", json_response), @@ -435,7 +474,10 @@ pub async fn authorize_post( || user.telegram_verified || user.signal_verified; if !is_verified { - return show_login_error("Please verify your account before logging in.", json_response); + return show_login_error( + "Please verify your account before logging in.", + json_response, + ); } let password_valid = match bcrypt::verify(&form.password, &user.password_hash) { Ok(valid) => valid, @@ -460,19 +502,24 @@ pub async fn authorize_post( ); } let channel_name = channel_display_name(user.preferred_comms_channel); - let redirect_url = format!( - "/oauth/authorize/2fa?request_uri={}&channel={}", + if json_response { + return Json(serde_json::json!({ + "needs_2fa": true, + "channel": channel_name + })) + .into_response(); + } + return redirect_see_other(&format!( + "/#/oauth/2fa?request_uri={}&channel={}", url_encode(&form.request_uri), url_encode(channel_name) - ); - return Redirect::temporary(&redirect_url).into_response(); + )); } Err(_) => { return show_login_error("An error occurred. Please try again.", json_response); } } } - let code = Code::generate(); let mut device_id: Option = extract_device_cookie(&headers); let mut new_cookie: Option = None; if form.remember_device { @@ -497,6 +544,60 @@ pub async fn authorize_post( }; let _ = db::upsert_account_device(&state.db, &user.did, &final_device_id).await; } + if db::set_authorization_did( + &state.db, + &form.request_uri, + &user.did, + device_id.as_deref(), + ) + .await + .is_err() + { + return show_login_error("An error occurred. Please try again.", json_response); + } + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec = requested_scope_str + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + let needs_consent = db::should_show_consent( + &state.db, + &user.did, + &request_data.parameters.client_id, + &requested_scopes, + ) + .await + .unwrap_or(true); + if needs_consent { + let consent_url = format!( + "/#/oauth/consent?request_uri={}", + url_encode(&form.request_uri) + ); + if json_response { + if let Some(cookie) = new_cookie { + return ( + StatusCode::OK, + [(SET_COOKIE, cookie)], + Json(serde_json::json!({"redirect_uri": consent_url})), + ) + .into_response(); + } + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); + } + if let Some(cookie) = new_cookie { + return ( + StatusCode::SEE_OTHER, + [(SET_COOKIE, cookie), (LOCATION, consent_url)], + ) + .into_response(); + } + return redirect_see_other(&consent_url); + } + let code = Code::generate(); if db::update_authorization_request( &state.db, &form.request_uri, @@ -513,8 +614,20 @@ 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(), ); - if let Some(cookie) = new_cookie { + if json_response { + if let Some(cookie) = new_cookie { + ( + StatusCode::OK, + [(SET_COOKIE, cookie)], + Json(serde_json::json!({"redirect_uri": redirect_url})), + ) + .into_response() + } else { + Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() + } + } else if let Some(cookie) = new_cookie { ( StatusCode::SEE_OTHER, [(SET_COOKIE, cookie), (LOCATION, redirect_url)], @@ -528,59 +641,69 @@ pub async fn authorize_post( pub async fn authorize_select( State(state): State, headers: HeaderMap, - Form(form): Form, + Json(form): Json, ) -> Response { + let json_error = |status: StatusCode, error: &str, description: &str| -> Response { + ( + status, + Json(serde_json::json!({ + "error": error, + "error_description": description + })), + ) + .into_response() + }; let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { Ok(Some(data)) => data, Ok(None) => { - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("Invalid or expired request_uri. Please start a new authorization request."), - )) - .into_response(); + "Invalid or expired request_uri. Please start a new authorization request.", + ); } Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } }; if request_data.expires_at < Utc::now() { let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("Authorization request has expired. Please start a new request."), - )) - .into_response(); + "Authorization request has expired. Please start a new request.", + ); } let device_id = match extract_device_cookie(&headers) { Some(id) => id, None => { - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("No device session found. Please sign in."), - )) - .into_response(); + "No device session found. Please sign in.", + ); } }; let account_valid = match db::verify_account_on_device(&state.db, &device_id, &form.did).await { Ok(valid) => valid, Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } }; if !account_valid { - return Html(templates::error_page( + return json_error( + StatusCode::FORBIDDEN, "access_denied", - Some("This account is not available on this device. Please sign in."), - )) - .into_response(); + "This account is not available on this device. Please sign in.", + ); } let user = match sqlx::query!( r#" @@ -597,16 +720,18 @@ pub async fn authorize_select( { Ok(Some(u)) => u, Ok(None) => { - return Html(templates::error_page( + return json_error( + StatusCode::FORBIDDEN, "access_denied", - Some("Account not found. Please sign in."), - )).into_response(); + "Account not found. Please sign in.", + ); } Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )).into_response(); + "An error occurred. Please try again.", + ); } }; let is_verified = user.email_verified @@ -614,11 +739,11 @@ pub async fn authorize_select( || user.telegram_verified || user.signal_verified; if !is_verified { - return Html(templates::error_page( + return json_error( + StatusCode::FORBIDDEN, "access_denied", - Some("Please verify your account before logging in."), - )) - .into_response(); + "Please verify your account before logging in.", + ); } if user.two_factor_enabled { let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; @@ -636,19 +761,18 @@ pub async fn authorize_select( ); } let channel_name = channel_display_name(user.preferred_comms_channel); - let redirect_url = format!( - "/oauth/authorize/2fa?request_uri={}&channel={}", - url_encode(&form.request_uri), - url_encode(channel_name) - ); - return Redirect::temporary(&redirect_url).into_response(); + return Json(serde_json::json!({ + "needs_2fa": true, + "channel": channel_name + })) + .into_response(); } Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } } } @@ -664,23 +788,39 @@ pub async fn authorize_select( .await .is_err() { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } let redirect_url = build_success_redirect( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), + request_data.parameters.response_mode.as_deref(), ); - redirect_see_other(&redirect_url) + Json(serde_json::json!({ + "redirect_uri": redirect_url + })) + .into_response() } -fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -> String { +fn build_success_redirect( + redirect_uri: &str, + code: &str, + state: Option<&str>, + response_mode: Option<&str>, +) -> String { let mut redirect_url = redirect_uri.to_string(); - let separator = if redirect_url.contains('?') { '&' } else { '?' }; + let use_fragment = response_mode == Some("fragment"); + let separator = if use_fragment { + '#' + } else if redirect_url.contains('?') { + '&' + } else { + '?' + }; redirect_url.push(separator); redirect_url.push_str(&format!("code={}", url_encode(code))); if let Some(req_state) = state { @@ -702,12 +842,32 @@ pub struct AuthorizeDenyResponse { pub async fn authorize_deny( State(state): State, - Form(form): Form, -) -> Result { - let request_data = db::get_authorization_request(&state.db, &form.request_uri) - .await? - .ok_or_else(|| OAuthError::InvalidRequest("Invalid request_uri".to_string()))?; - db::delete_authorization_request(&state.db, &form.request_uri).await?; + Json(form): Json, +) -> Response { + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid request_uri" + })), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred" + })), + ) + .into_response(); + } + }; + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; let redirect_uri = &request_data.parameters.redirect_uri; let mut redirect_url = redirect_uri.to_string(); let separator = if redirect_url.contains('?') { '&' } else { '?' }; @@ -717,7 +877,10 @@ pub async fn authorize_deny( if let Some(state) = &request_data.parameters.state { redirect_url.push_str(&format!("&state={}", url_encode(state))); } - Ok(redirect_see_other(&redirect_url)) + Json(serde_json::json!({ + "redirect_uri": redirect_url + })) + .into_response() } #[derive(Debug, Deserialize)] @@ -746,106 +909,452 @@ pub async fn authorize_2fa_get( let challenge = match db::get_2fa_challenge(&state.db, &query.request_uri).await { Ok(Some(c)) => c, Ok(None) => { - return Html(templates::error_page( + return redirect_to_frontend_error( "invalid_request", - Some("No 2FA challenge found. Please start over."), - )) - .into_response(); + "No 2FA challenge found. Please start over.", + ); } Err(_) => { - return Html(templates::error_page( + return redirect_to_frontend_error( "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } }; if challenge.expires_at < Utc::now() { let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; - return Html(templates::error_page( + return redirect_to_frontend_error( "invalid_request", - Some("2FA code has expired. Please start over."), - )) - .into_response(); + "2FA code has expired. Please start over.", + ); } let _request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { Ok(Some(d)) => d, Ok(None) => { - return Html(templates::error_page( + return redirect_to_frontend_error( "invalid_request", - Some("Authorization request not found. Please start over."), - )) - .into_response(); + "Authorization request not found. Please start over.", + ); } Err(_) => { - return Html(templates::error_page( + return redirect_to_frontend_error( "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } }; let channel = query.channel.as_deref().unwrap_or("email"); - Html(templates::two_factor_page( - &query.request_uri, - channel, - None, + redirect_see_other(&format!( + "/#/oauth/2fa?request_uri={}&channel={}", + url_encode(&query.request_uri), + url_encode(channel) )) +} + +#[derive(Debug, Serialize)] +pub struct ScopeInfo { + pub scope: String, + pub category: String, + pub required: bool, + pub description: String, + pub display_name: String, + pub granted: Option, +} + +#[derive(Debug, Serialize)] +pub struct ConsentResponse { + pub request_uri: String, + pub client_id: String, + pub client_name: Option, + pub client_uri: Option, + pub logo_uri: Option, + pub scopes: Vec, + pub show_consent: bool, + pub did: String, +} + +#[derive(Debug, Deserialize)] +pub struct ConsentQuery { + pub request_uri: String, +} + +#[derive(Debug, Deserialize)] +pub struct ConsentSubmit { + pub request_uri: String, + pub approved_scopes: Vec, + pub remember: bool, +} + +pub async fn consent_get( + State(state): State, + Query(query): Query, +) -> Response { + let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid or expired request_uri" + })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": format!("Database error: {:?}", e) + })), + ) + .into_response(); + } + }; + if request_data.expires_at < Utc::now() { + let _ = db::delete_authorization_request(&state.db, &query.request_uri).await; + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request has expired" + })), + ) + .into_response(); + } + let did = match &request_data.did { + Some(d) => d.clone(), + None => { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Not authenticated" + })), + ) + .into_response(); + } + }; + let client_cache = ClientMetadataCache::new(3600); + let client_metadata = client_cache + .get(&request_data.parameters.client_id) + .await + .ok(); + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect(); + let preferences = + db::get_scope_preferences(&state.db, &did, &request_data.parameters.client_id) + .await + .unwrap_or_default(); + let pref_map: std::collections::HashMap<_, _> = preferences + .iter() + .map(|p| (p.scope.as_str(), p.granted)) + .collect(); + let requested_scope_strings: Vec = + requested_scopes.iter().map(|s| s.to_string()).collect(); + let show_consent = db::should_show_consent( + &state.db, + &did, + &request_data.parameters.client_id, + &requested_scope_strings, + ) + .await + .unwrap_or(true); + let mut scopes = Vec::new(); + for scope in &requested_scopes { + let (category, required, description, display_name) = + if let Some(def) = crate::oauth::scopes::SCOPE_DEFINITIONS.get(*scope) { + ( + def.category.display_name().to_string(), + def.required, + def.description.to_string(), + def.display_name.to_string(), + ) + } else if scope.starts_with("ref:") { + ( + "Reference".to_string(), + false, + "Referenced scope".to_string(), + scope.to_string(), + ) + } else { + ( + "Other".to_string(), + false, + format!("Access to {}", scope), + scope.to_string(), + ) + }; + let granted = pref_map.get(*scope).copied(); + scopes.push(ScopeInfo { + scope: scope.to_string(), + category, + required, + description, + display_name, + granted, + }); + } + Json(ConsentResponse { + request_uri: query.request_uri.clone(), + client_id: request_data.parameters.client_id.clone(), + client_name: client_metadata.as_ref().and_then(|m| m.client_name.clone()), + client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()), + logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()), + scopes, + show_consent, + did, + }) + .into_response() +} + +pub async fn consent_post( + State(state): State, + Json(form): Json, +) -> Response { + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid or expired request_uri" + })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": format!("Database error: {:?}", e) + })), + ) + .into_response(); + } + }; + if request_data.expires_at < Utc::now() { + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request has expired" + })), + ) + .into_response(); + } + let did = match &request_data.did { + Some(d) => d.clone(), + None => { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Not authenticated" + })), + ) + .into_response(); + } + }; + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect(); + let has_granular_scopes = requested_scopes.iter().any(|s| { + s.starts_with("repo:") + || s.starts_with("blob:") + || s.starts_with("rpc:") + || s.starts_with("account:") + || s.starts_with("identity:") + }); + let user_denied_some_granular = has_granular_scopes + && requested_scopes + .iter() + .filter(|s| { + s.starts_with("repo:") + || s.starts_with("blob:") + || s.starts_with("rpc:") + || s.starts_with("account:") + || s.starts_with("identity:") + }) + .any(|s| !form.approved_scopes.contains(&s.to_string())); + let atproto_was_requested = requested_scopes.contains(&"atproto"); + if atproto_was_requested + && !has_granular_scopes + && !form.approved_scopes.contains(&"atproto".to_string()) + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "The atproto scope was requested and must be approved" + })), + ) + .into_response(); + } + let final_approved: Vec = if user_denied_some_granular { + form.approved_scopes + .iter() + .filter(|s| *s != "atproto") + .cloned() + .collect() + } else { + form.approved_scopes.clone() + }; + if final_approved.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "At least one scope must be approved" + })), + ) + .into_response(); + } + let approved_scope_str = final_approved.join(" "); + let has_valid_scope = final_approved.iter().all(|s| { + s == "atproto" + || s == "transition:generic" + || s == "transition:chat.bsky" + || s == "transition:email" + || s.starts_with("repo:") + || s.starts_with("blob:") + || s.starts_with("rpc:") + || s.starts_with("account:") + || s.starts_with("include:") + }); + if !has_valid_scope { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid scope format" + })), + ) + .into_response(); + } + if form.remember { + let preferences: Vec = requested_scopes + .iter() + .map(|s| db::ScopePreference { + scope: s.to_string(), + granted: form.approved_scopes.contains(&s.to_string()), + }) + .collect(); + let _ = db::upsert_scope_preferences( + &state.db, + &did, + &request_data.parameters.client_id, + &preferences, + ) + .await; + } + if let Err(e) = + db::update_request_scope(&state.db, &form.request_uri, &approved_scope_str).await + { + tracing::warn!("Failed to update request scope: {:?}", e); + } + let code = Code::generate(); + if db::update_authorization_request( + &state.db, + &form.request_uri, + &did, + request_data.device_id.as_deref(), + &code.0, + ) + .await + .is_err() + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to complete authorization" + })), + ) + .into_response(); + } + let redirect_url = build_success_redirect( + &request_data.parameters.redirect_uri, + &code.0, + request_data.parameters.state.as_deref(), + request_data.parameters.response_mode.as_deref(), + ); + Json(serde_json::json!({ + "redirect_uri": redirect_url + })) .into_response() } pub async fn authorize_2fa_post( State(state): State, headers: HeaderMap, - Form(form): Form, + Json(form): Json, ) -> Response { + let json_error = |status: StatusCode, error: &str, description: &str| -> Response { + ( + status, + Json(serde_json::json!({ + "error": error, + "error_description": description + })), + ) + .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 ( - axum::http::StatusCode::TOO_MANY_REQUESTS, - Html(templates::error_page( - "RateLimitExceeded", - Some("Too many attempts. Please try again later."), - )), - ) - .into_response(); + return json_error( + StatusCode::TOO_MANY_REQUESTS, + "RateLimitExceeded", + "Too many attempts. Please try again later.", + ); } let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await { Ok(Some(c)) => c, Ok(None) => { - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("No 2FA challenge found. Please start over."), - )) - .into_response(); + "No 2FA challenge found. Please start over.", + ); } Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } }; if challenge.expires_at < Utc::now() { let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("2FA code has expired. Please start over."), - )) - .into_response(); + "2FA code has expired. Please start over.", + ); } if challenge.attempts >= MAX_2FA_ATTEMPTS { let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; - return Html(templates::error_page( + return json_error( + StatusCode::FORBIDDEN, "access_denied", - Some("Too many failed attempts. Please start over."), - )) - .into_response(); + "Too many failed attempts. Please start over.", + ); } let code_valid: bool = form .code @@ -855,57 +1364,28 @@ pub async fn authorize_2fa_post( .into(); if !code_valid { let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; - let channel = match sqlx::query_scalar!( - r#"SELECT preferred_comms_channel as "channel: CommsChannel" FROM users WHERE did = $1"#, - challenge.did - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(ch)) => channel_display_name(ch).to_string(), - Ok(None) | Err(_) => "email".to_string(), - }; - let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await - { - Ok(Some(d)) => d, - Ok(None) => { - return Html(templates::error_page( - "invalid_request", - Some("Authorization request not found. Please start over."), - )) - .into_response(); - } - Err(_) => { - return Html(templates::error_page( - "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); - } - }; - return Html(templates::two_factor_page( - &form.request_uri, - &channel, - Some("Invalid verification code. Please try again."), - )) - .into_response(); + return json_error( + StatusCode::FORBIDDEN, + "invalid_code", + "Invalid verification code. Please try again.", + ); } let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { Ok(Some(d)) => d, Ok(None) => { - return Html(templates::error_page( + return json_error( + StatusCode::BAD_REQUEST, "invalid_request", - Some("Authorization request not found."), - )) - .into_response(); + "Authorization request not found.", + ); } Err(_) => { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred."), - )) - .into_response(); + "An error occurred.", + ); } }; let code = Code::generate(); @@ -920,16 +1400,20 @@ pub async fn authorize_2fa_post( .await .is_err() { - return Html(templates::error_page( + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, "server_error", - Some("An error occurred. Please try again."), - )) - .into_response(); + "An error occurred. Please try again.", + ); } let redirect_url = build_success_redirect( &request_data.parameters.redirect_uri, &code.0, request_data.parameters.state.as_deref(), + request_data.parameters.response_mode.as_deref(), ); - redirect_see_other(&redirect_url) + Json(serde_json::json!({ + "redirect_uri": redirect_url + })) + .into_response() } diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs index 569791d..aecd98e 100644 --- a/src/oauth/endpoints/metadata.rs +++ b/src/oauth/endpoints/metadata.rs @@ -79,6 +79,17 @@ pub async fn oauth_authorization_server( "atproto".to_string(), "transition:generic".to_string(), "transition:chat.bsky".to_string(), + "repo:*".to_string(), + "repo:*?action=create".to_string(), + "repo:*?action=read".to_string(), + "repo:*?action=update".to_string(), + "repo:*?action=delete".to_string(), + "blob:*/*".to_string(), + "rpc:*".to_string(), + "account:*".to_string(), + "account:*?action=read".to_string(), + "account:*?action=write".to_string(), + "identity:*".to_string(), ]), response_types_supported: vec!["code".to_string()], response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]), diff --git a/src/oauth/endpoints/par.rs b/src/oauth/endpoints/par.rs index b27464e..8df5353 100644 --- a/src/oauth/endpoints/par.rs +++ b/src/oauth/endpoints/par.rs @@ -1,14 +1,16 @@ use crate::oauth::{ AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId, - client::ClientMetadataCache, db, + client::ClientMetadataCache, + db, + scopes::{ParsedScope, parse_scope}, }; use crate::state::{AppState, RateLimitKind}; -use axum::{Form, Json, extract::State, http::HeaderMap}; +use axum::body::Bytes; +use axum::{Json, extract::State, http::HeaderMap}; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; const PAR_EXPIRY_SECONDS: i64 = 600; -const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"]; #[derive(Debug, Deserialize)] pub struct ParRequest { @@ -24,6 +26,8 @@ pub struct ParRequest { #[serde(default)] pub code_challenge_method: Option, #[serde(default)] + pub response_mode: Option, + #[serde(default)] pub login_hint: Option, #[serde(default)] pub dpop_jkt: Option, @@ -44,8 +48,24 @@ pub struct ParResponse { pub async fn pushed_authorization_request( State(state): State, headers: HeaderMap, - Form(request): Form, + body: Bytes, ) -> Result<(axum::http::StatusCode, Json), OAuthError> { + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let request: ParRequest = if content_type.starts_with("application/json") { + serde_json::from_slice(&body) + .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))? + } else if content_type.starts_with("application/x-www-form-urlencoded") { + serde_urlencoded::from_bytes(&body) + .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))? + } else { + return Err(OAuthError::InvalidRequest( + "Content-Type must be application/json or application/x-www-form-urlencoded" + .to_string(), + )); + }; let client_ip = crate::rate_limit::extract_client_ip(&headers, None); if !state .check_rate_limit(RateLimitKind::OAuthPar, &client_ip) @@ -77,6 +97,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 parameters = AuthorizationRequestParameters { response_type: request.response_type, client_id: request.client_id.clone(), @@ -85,6 +115,7 @@ pub async fn pushed_authorization_request( state: request.state, code_challenge: code_challenge.clone(), code_challenge_method: code_challenge_method.to_string(), + response_mode, login_hint: request.login_hint, dpop_jkt: request.dpop_jkt, extra: None, @@ -149,19 +180,45 @@ fn validate_scope( if requested_scopes.is_empty() { return Ok(Some("atproto".to_string())); } + let mut has_transition = false; + let mut has_granular = false; + for scope in &requested_scopes { - if !SUPPORTED_SCOPES.contains(scope) { - return Err(OAuthError::InvalidScope(format!( - "Unsupported scope: {}. Supported scopes: {}", - scope, - SUPPORTED_SCOPES.join(", ") - ))); + let parsed = parse_scope(scope); + match &parsed { + ParsedScope::Unknown(_) => { + return Err(OAuthError::InvalidScope(format!( + "Unsupported scope: {}", + scope + ))); + } + ParsedScope::TransitionGeneric + | ParsedScope::TransitionChat + | ParsedScope::TransitionEmail => { + has_transition = true; + } + ParsedScope::Repo(_) + | ParsedScope::Blob(_) + | ParsedScope::Rpc(_) + | ParsedScope::Account(_) + | ParsedScope::Identity(_) + | ParsedScope::Include(_) => { + has_granular = true; + } + ParsedScope::Atproto => {} } } + + if has_transition && has_granular { + return Err(OAuthError::InvalidScope( + "Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string() + )); + } + if let Some(client_scope) = &client_metadata.scope { let client_scopes: Vec<&str> = client_scope.split_whitespace().collect(); for scope in &requested_scopes { - if !client_scopes.contains(scope) { + if !client_scopes.iter().any(|cs| scope_matches(cs, scope)) { return Err(OAuthError::InvalidScope(format!( "Scope '{}' not registered for this client", scope @@ -171,3 +228,26 @@ fn validate_scope( } Ok(Some(requested_scopes.join(" "))) } + +fn scope_matches(client_scope: &str, requested_scope: &str) -> bool { + if client_scope == requested_scope { + return true; + } + + fn get_resource_type(scope: &str) -> &str { + let base = scope.split('?').next().unwrap_or(scope); + base.split(':').next().unwrap_or(base) + } + + let client_type = get_resource_type(client_scope); + let requested_type = get_resource_type(requested_scope); + + if client_type == requested_type { + let client_base = client_scope.split('?').next().unwrap_or(client_scope); + if client_base.contains('*') { + return true; + } + } + + false +} diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs index f6f2ff4..78bbc98 100644 --- a/src/oauth/endpoints/token/grants.rs +++ b/src/oauth/endpoints/token/grants.rs @@ -36,9 +36,10 @@ pub async fn handle_authorization_code_grant( )); } if let Some(request_client_id) = &request.client_id - && request_client_id != &auth_request.client_id { - return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); - } + && request_client_id != &auth_request.client_id + { + return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); + } let did = auth_request .did .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; @@ -65,11 +66,12 @@ pub async fn handle_authorization_code_grant( verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?; verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; if let Some(redirect_uri) = &request.redirect_uri - && redirect_uri != &auth_request.parameters.redirect_uri { - return Err(OAuthError::InvalidGrant( - "redirect_uri mismatch".to_string(), - )); - } + && redirect_uri != &auth_request.parameters.redirect_uri + { + return Err(OAuthError::InvalidGrant( + "redirect_uri mismatch".to_string(), + )); + } let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); @@ -83,11 +85,12 @@ pub async fn handle_authorization_code_grant( )); } if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt - && &result.jkt != expected_jkt { - return Err(OAuthError::InvalidDpopProof( - "DPoP key binding mismatch".to_string(), - )); - } + && &result.jkt != expected_jkt + { + return Err(OAuthError::InvalidDpopProof( + "DPoP key binding mismatch".to_string(), + )); + } Some(result.jkt) } else if auth_request.parameters.dpop_jkt.is_some() { return Err(OAuthError::InvalidRequest( @@ -96,10 +99,18 @@ pub async fn handle_authorization_code_grant( } else { None }; + if let Err(e) = db::revoke_tokens_for_client(&state.db, &did, &auth_request.client_id).await { + tracing::warn!("Failed to revoke previous tokens for client: {:?}", e); + } let token_id = TokenId::generate(); let refresh_token = RefreshToken::generate(); let now = Utc::now(); - let access_token = create_access_token(&token_id.0, &did, dpop_jkt.as_deref())?; + let access_token = create_access_token( + &token_id.0, + &did, + dpop_jkt.as_deref(), + auth_request.parameters.scope.as_deref(), + )?; let token_data = TokenData { did: did.clone(), token_id: token_id.0.clone(), @@ -179,11 +190,12 @@ pub async fn handle_refresh_token_grant( )); } if let Some(expected_jkt) = &token_data.parameters.dpop_jkt - && &result.jkt != expected_jkt { - return Err(OAuthError::InvalidDpopProof( - "DPoP key binding mismatch".to_string(), - )); - } + && &result.jkt != expected_jkt + { + return Err(OAuthError::InvalidDpopProof( + "DPoP key binding mismatch".to_string(), + )); + } Some(result.jkt) } else if token_data.parameters.dpop_jkt.is_some() { return Err(OAuthError::InvalidRequest( @@ -203,7 +215,12 @@ pub async fn handle_refresh_token_grant( new_expires_at, ) .await?; - let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?; + let access_token = create_access_token( + &new_token_id.0, + &token_data.did, + dpop_jkt.as_deref(), + token_data.scope.as_deref(), + )?; let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); diff --git a/src/oauth/endpoints/token/helpers.rs b/src/oauth/endpoints/token/helpers.rs index 214daf1..643cf1c 100644 --- a/src/oauth/endpoints/token/helpers.rs +++ b/src/oauth/endpoints/token/helpers.rs @@ -36,12 +36,14 @@ pub fn create_access_token( token_id: &str, sub: &str, dpop_jkt: Option<&str>, + scope: Option<&str>, ) -> Result { use serde_json::json; let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let issuer = format!("https://{}", pds_hostname); let now = Utc::now().timestamp(); let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; + let actual_scope = scope.unwrap_or("atproto"); let mut payload = json!({ "iss": issuer, "sub": sub, @@ -49,7 +51,7 @@ pub fn create_access_token( "iat": now, "exp": exp, "jti": token_id, - "scope": "atproto" + "scope": actual_scope }); if let Some(jkt) = dpop_jkt { payload["cnf"] = json!({ "jkt": jkt }); diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs index 306219f..51182f1 100644 --- a/src/oauth/endpoints/token/mod.rs +++ b/src/oauth/endpoints/token/mod.rs @@ -5,7 +5,8 @@ mod types; use crate::oauth::OAuthError; use crate::state::{AppState, RateLimitKind}; -use axum::{Form, Json, extract::State, http::HeaderMap}; +use axum::body::Bytes; +use axum::{Json, extract::State, http::HeaderMap}; pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant}; pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify_pkce}; @@ -17,21 +18,39 @@ pub use types::{TokenRequest, TokenResponse}; 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(); - } + && 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(); - } + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } "unknown".to_string() } pub async fn token_endpoint( State(state): State, headers: HeaderMap, - Form(request): Form, + body: Bytes, ) -> Result<(HeaderMap, Json), OAuthError> { + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let request: TokenRequest = if content_type.starts_with("application/json") { + serde_json::from_slice(&body) + .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))? + } else if content_type.starts_with("application/x-www-form-urlencoded") { + serde_urlencoded::from_bytes(&body) + .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))? + } else { + return Err(OAuthError::InvalidRequest( + "Content-Type must be application/json or application/x-www-form-urlencoded" + .to_string(), + )); + }; let client_ip = extract_client_ip(&headers); if !state .check_rate_limit(RateLimitKind::OAuthToken, &client_ip) diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 4236131..8b20c0a 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -4,12 +4,12 @@ pub mod dpop; pub mod endpoints; pub mod error; pub mod jwks; -pub mod templates; +pub mod scopes; pub mod types; pub mod verify; pub use error::OAuthError; -pub use templates::{DeviceAccount, mask_email}; +pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions}; pub use types::*; pub use verify::{ OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token, diff --git a/src/oauth/scopes/definitions.rs b/src/oauth/scopes/definitions.rs new file mode 100644 index 0000000..02104ce --- /dev/null +++ b/src/oauth/scopes/definitions.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeCategory { + Core, + Transition, + Repo, + Blob, + Rpc, + Account, +} + +impl ScopeCategory { + pub fn display_name(&self) -> &'static str { + match self { + ScopeCategory::Core => "Core Access", + ScopeCategory::Transition => "Transition", + ScopeCategory::Repo => "Repository", + ScopeCategory::Blob => "Media", + ScopeCategory::Rpc => "API Access", + ScopeCategory::Account => "Account", + } + } +} + +#[derive(Debug, Clone)] +pub struct ScopeDefinition { + pub scope: &'static str, + pub category: ScopeCategory, + pub required: bool, + pub description: &'static str, + pub display_name: &'static str, +} + +pub static SCOPE_DEFINITIONS: LazyLock> = + LazyLock::new(|| { + let definitions = vec![ + ScopeDefinition { + scope: "atproto", + category: ScopeCategory::Core, + required: true, + description: "Use AT Protocol OAuth (required for all sessions)", + display_name: "AT Protocol", + }, + ScopeDefinition { + scope: "transition:generic", + category: ScopeCategory::Transition, + required: false, + description: "Generic transition scope for compatibility", + display_name: "Transition Access", + }, + ScopeDefinition { + scope: "transition:chat.bsky", + category: ScopeCategory::Transition, + required: false, + description: "Access to Bluesky chat features", + display_name: "Chat Access", + }, + ScopeDefinition { + scope: "transition:email", + category: ScopeCategory::Account, + required: false, + description: "Read your account email address", + display_name: "Email Access", + }, + ScopeDefinition { + scope: "repo:*?action=create", + category: ScopeCategory::Repo, + required: false, + description: "Create new records in your repository", + display_name: "Create Records", + }, + ScopeDefinition { + scope: "repo:*?action=update", + category: ScopeCategory::Repo, + required: false, + description: "Update existing records in your repository", + display_name: "Update Records", + }, + ScopeDefinition { + scope: "repo:*?action=delete", + category: ScopeCategory::Repo, + required: false, + description: "Delete records from your repository", + display_name: "Delete Records", + }, + ScopeDefinition { + scope: "blob:*/*", + category: ScopeCategory::Blob, + required: false, + description: "Upload images, videos, and other media files", + display_name: "Upload Media", + }, + ]; + + definitions.into_iter().map(|d| (d.scope, d)).collect() + }); + +#[allow(dead_code)] +pub fn get_scope_definition(scope: &str) -> Option<&'static ScopeDefinition> { + SCOPE_DEFINITIONS.get(scope) +} + +#[allow(dead_code)] +pub fn is_valid_scope(scope: &str) -> bool { + if SCOPE_DEFINITIONS.contains_key(scope) { + return true; + } + if scope.starts_with("ref:") { + return true; + } + false +} + +#[allow(dead_code)] +pub fn get_required_scopes() -> Vec<&'static str> { + SCOPE_DEFINITIONS + .values() + .filter(|d| d.required) + .map(|d| d.scope) + .collect() +} + +#[allow(dead_code)] +pub fn format_scope_for_display(scope: &str) -> String { + if let Some(def) = get_scope_definition(scope) { + def.description.to_string() + } else if scope.starts_with("ref:") { + "Referenced scope".to_string() + } else { + format!("Access to {}", scope) + } +} diff --git a/src/oauth/scopes/error.rs b/src/oauth/scopes/error.rs new file mode 100644 index 0000000..ba1d41c --- /dev/null +++ b/src/oauth/scopes/error.rs @@ -0,0 +1,39 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug, Clone)] +pub enum ScopeError { + InsufficientScope { required: String, message: String }, + InvalidScope(String), +} + +impl std::fmt::Display for ScopeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ScopeError::InsufficientScope { message, .. } => write!(f, "{}", message), + ScopeError::InvalidScope(msg) => write!(f, "Invalid scope: {}", msg), + } + } +} + +impl std::error::Error for ScopeError {} + +impl IntoResponse for ScopeError { + fn into_response(self) -> Response { + let (status, error_code, message) = match &self { + ScopeError::InsufficientScope { message, .. } => { + (StatusCode::FORBIDDEN, "InsufficientScope", message.clone()) + } + ScopeError::InvalidScope(msg) => (StatusCode::BAD_REQUEST, "InvalidScope", msg.clone()), + }; + ( + status, + axum::Json(json!({ + "error": error_code, + "message": message + })), + ) + .into_response() + } +} diff --git a/src/oauth/scopes/mod.rs b/src/oauth/scopes/mod.rs new file mode 100644 index 0000000..67f9e64 --- /dev/null +++ b/src/oauth/scopes/mod.rs @@ -0,0 +1,12 @@ +mod definitions; +mod error; +mod parser; +mod permissions; + +pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition}; +pub use error::ScopeError; +pub use parser::{ + AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, + ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, +}; +pub use permissions::ScopePermissions; diff --git a/src/oauth/scopes/parser.rs b/src/oauth/scopes/parser.rs new file mode 100644 index 0000000..ba71e8f --- /dev/null +++ b/src/oauth/scopes/parser.rs @@ -0,0 +1,483 @@ +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedScope { + Atproto, + TransitionGeneric, + TransitionChat, + TransitionEmail, + Repo(RepoScope), + Blob(BlobScope), + Rpc(RpcScope), + Account(AccountScope), + Identity(IdentityScope), + Include(IncludeScope), + Unknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncludeScope { + pub nsid: String, + pub aud: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoScope { + pub collection: Option, + pub actions: HashSet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RepoAction { + Create, + Update, + Delete, +} + +impl RepoAction { + pub fn parse_str(s: &str) -> Option { + match s { + "create" => Some(Self::Create), + "update" => Some(Self::Update), + "delete" => Some(Self::Delete), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlobScope { + pub accept: HashSet, +} + +impl BlobScope { + pub fn matches_mime(&self, mime: &str) -> bool { + if self.accept.is_empty() || self.accept.contains("*/*") { + return true; + } + for pattern in &self.accept { + if pattern == mime { + return true; + } + if let Some(prefix) = pattern.strip_suffix("/*") + && mime.starts_with(prefix) + && mime.chars().nth(prefix.len()) == Some('/') + { + return true; + } + } + false + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RpcScope { + pub lxm: Option, + pub aud: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountScope { + pub attr: AccountAttr, + pub action: AccountAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AccountAttr { + Email, + Handle, + Repo, + Status, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityScope { + pub attr: IdentityAttr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IdentityAttr { + Handle, + Wildcard, +} + +impl AccountAttr { + pub fn parse_str(s: &str) -> Option { + match s { + "email" => Some(Self::Email), + "handle" => Some(Self::Handle), + "repo" => Some(Self::Repo), + "status" => Some(Self::Status), + _ => None, + } + } +} + +impl IdentityAttr { + pub fn parse_str(s: &str) -> Option { + match s { + "handle" => Some(Self::Handle), + "*" => Some(Self::Wildcard), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AccountAction { + Read, + Manage, +} + +impl AccountAction { + pub fn parse_str(s: &str) -> Option { + match s { + "read" => Some(Self::Read), + "manage" => Some(Self::Manage), + _ => None, + } + } +} + +fn parse_query_params(query: &str) -> HashMap> { + let mut params: HashMap> = HashMap::new(); + for part in query.split('&') { + if let Some((key, value)) = part.split_once('=') { + params + .entry(key.to_string()) + .or_default() + .push(value.to_string()); + } + } + params +} + +pub fn parse_scope(scope: &str) -> ParsedScope { + match scope { + "atproto" => return ParsedScope::Atproto, + "transition:generic" => return ParsedScope::TransitionGeneric, + "transition:chat.bsky" => return ParsedScope::TransitionChat, + "transition:email" => return ParsedScope::TransitionEmail, + _ => {} + } + + let (base, query) = scope.split_once('?').unwrap_or((scope, "")); + let params = parse_query_params(query); + + if let Some(rest) = base.strip_prefix("repo:") { + let collection = if rest == "*" || rest.is_empty() { + None + } else { + Some(rest.to_string()) + }; + + let mut actions = HashSet::new(); + if let Some(action_values) = params.get("action") { + for action_str in action_values { + if let Some(action) = RepoAction::parse_str(action_str) { + actions.insert(action); + } + } + } + if actions.is_empty() { + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + } + + return ParsedScope::Repo(RepoScope { + collection, + actions, + }); + } + + if base == "repo" { + let mut actions = HashSet::new(); + if let Some(action_values) = params.get("action") { + for action_str in action_values { + if let Some(action) = RepoAction::parse_str(action_str) { + actions.insert(action); + } + } + } + if actions.is_empty() { + actions.insert(RepoAction::Create); + actions.insert(RepoAction::Update); + actions.insert(RepoAction::Delete); + } + return ParsedScope::Repo(RepoScope { + collection: None, + actions, + }); + } + + if base.starts_with("blob") { + let positional = base.strip_prefix("blob:").unwrap_or(""); + let mut accept = HashSet::new(); + + if !positional.is_empty() { + accept.insert(positional.to_string()); + } + if let Some(accept_values) = params.get("accept") { + for v in accept_values { + accept.insert(v.to_string()); + } + } + + return ParsedScope::Blob(BlobScope { accept }); + } + + if base.starts_with("rpc") { + let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string()); + let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned())); + let aud = params.get("aud").and_then(|v| v.first().cloned()); + + let is_lxm_wildcard = lxm.as_deref() == Some("*") || lxm.is_none(); + let is_aud_wildcard = aud.as_deref() == Some("*"); + if is_lxm_wildcard && is_aud_wildcard { + return ParsedScope::Unknown(scope.to_string()); + } + + return ParsedScope::Rpc(RpcScope { lxm, aud }); + } + + if let Some(attr_str) = base.strip_prefix("account:") + && let Some(attr) = AccountAttr::parse_str(attr_str) + { + let action = params + .get("action") + .and_then(|v| v.first()) + .and_then(|s| AccountAction::parse_str(s)) + .unwrap_or(AccountAction::Read); + + return ParsedScope::Account(AccountScope { attr, action }); + } + + if let Some(attr_str) = base.strip_prefix("identity:") + && let Some(attr) = IdentityAttr::parse_str(attr_str) + { + return ParsedScope::Identity(IdentityScope { attr }); + } + + if let Some(nsid) = base.strip_prefix("include:") { + let aud = params.get("aud").and_then(|v| v.first().cloned()); + return ParsedScope::Include(IncludeScope { + nsid: nsid.to_string(), + aud, + }); + } + + ParsedScope::Unknown(scope.to_string()) +} + +pub fn parse_scope_string(scope_str: &str) -> Vec { + scope_str.split_whitespace().map(parse_scope).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_atproto() { + assert_eq!(parse_scope("atproto"), ParsedScope::Atproto); + } + + #[test] + fn test_parse_transition_scopes() { + assert_eq!( + parse_scope("transition:generic"), + ParsedScope::TransitionGeneric + ); + assert_eq!( + parse_scope("transition:chat.bsky"), + ParsedScope::TransitionChat + ); + assert_eq!( + parse_scope("transition:email"), + ParsedScope::TransitionEmail + ); + } + + #[test] + fn test_parse_repo_wildcard() { + let scope = parse_scope("repo:*?action=create"); + match scope { + ParsedScope::Repo(r) => { + assert!(r.collection.is_none()); + assert!(r.actions.contains(&RepoAction::Create)); + assert!(!r.actions.contains(&RepoAction::Update)); + } + _ => panic!("Expected Repo scope"), + } + } + + #[test] + fn test_parse_repo_collection() { + let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete"); + match scope { + ParsedScope::Repo(r) => { + assert_eq!(r.collection, Some("app.bsky.feed.post".to_string())); + assert!(r.actions.contains(&RepoAction::Create)); + assert!(r.actions.contains(&RepoAction::Delete)); + assert!(!r.actions.contains(&RepoAction::Update)); + } + _ => panic!("Expected Repo scope"), + } + } + + #[test] + fn test_parse_repo_no_actions_means_all() { + let scope = parse_scope("repo:app.bsky.feed.post"); + match scope { + ParsedScope::Repo(r) => { + assert!(r.actions.contains(&RepoAction::Create)); + assert!(r.actions.contains(&RepoAction::Update)); + assert!(r.actions.contains(&RepoAction::Delete)); + } + _ => panic!("Expected Repo scope"), + } + } + + #[test] + fn test_parse_blob_wildcard() { + let scope = parse_scope("blob:*/*"); + match scope { + ParsedScope::Blob(b) => { + assert!(b.accept.contains("*/*")); + assert!(b.matches_mime("image/png")); + assert!(b.matches_mime("video/mp4")); + } + _ => panic!("Expected Blob scope"), + } + } + + #[test] + fn test_parse_blob_specific() { + let scope = parse_scope("blob?accept=image/*&accept=video/*"); + match scope { + ParsedScope::Blob(b) => { + assert!(b.matches_mime("image/png")); + assert!(b.matches_mime("image/jpeg")); + assert!(b.matches_mime("video/mp4")); + assert!(!b.matches_mime("text/plain")); + } + _ => panic!("Expected Blob scope"), + } + } + + #[test] + fn test_parse_rpc() { + let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app"); + match scope { + ParsedScope::Rpc(r) => { + assert_eq!(r.lxm, Some("app.bsky.feed.getTimeline".to_string())); + assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); + } + _ => panic!("Expected Rpc scope"), + } + } + + #[test] + fn test_parse_account() { + let scope = parse_scope("account:email?action=read"); + match scope { + ParsedScope::Account(a) => { + assert_eq!(a.attr, AccountAttr::Email); + assert_eq!(a.action, AccountAction::Read); + } + _ => panic!("Expected Account scope"), + } + + let scope2 = parse_scope("account:repo?action=manage"); + match scope2 { + ParsedScope::Account(a) => { + assert_eq!(a.attr, AccountAttr::Repo); + assert_eq!(a.action, AccountAction::Manage); + } + _ => panic!("Expected Account scope"), + } + } + + #[test] + fn test_parse_scope_string() { + let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*"); + assert_eq!(scopes.len(), 3); + assert_eq!(scopes[0], ParsedScope::Atproto); + match &scopes[1] { + ParsedScope::Repo(_) => {} + _ => panic!("Expected Repo"), + } + match &scopes[2] { + ParsedScope::Blob(_) => {} + _ => panic!("Expected Blob"), + } + } + + #[test] + fn test_parse_include() { + let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); + match scope { + ParsedScope::Include(i) => { + assert_eq!(i.nsid, "app.bsky.authFullApp"); + assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string())); + } + _ => panic!("Expected Include scope"), + } + + let scope2 = parse_scope("include:com.example.authBasicFeatures"); + match scope2 { + ParsedScope::Include(i) => { + assert_eq!(i.nsid, "com.example.authBasicFeatures"); + assert_eq!(i.aud, None); + } + _ => panic!("Expected Include scope"), + } + } + + #[test] + fn test_parse_identity() { + let scope = parse_scope("identity:handle"); + match scope { + ParsedScope::Identity(i) => { + assert_eq!(i.attr, IdentityAttr::Handle); + } + _ => panic!("Expected Identity scope"), + } + + let scope2 = parse_scope("identity:*"); + match scope2 { + ParsedScope::Identity(i) => { + assert_eq!(i.attr, IdentityAttr::Wildcard); + } + _ => panic!("Expected Identity scope"), + } + } + + #[test] + fn test_parse_account_status() { + let scope = parse_scope("account:status?action=read"); + match scope { + ParsedScope::Account(a) => { + assert_eq!(a.attr, AccountAttr::Status); + assert_eq!(a.action, AccountAction::Read); + } + _ => panic!("Expected Account scope"), + } + } + + #[test] + fn test_rpc_wildcard_aud_forbidden() { + let scope = parse_scope("rpc:*?aud=*"); + assert!(matches!(scope, ParsedScope::Unknown(_))); + + let scope2 = parse_scope("rpc?aud=*"); + assert!(matches!(scope2, ParsedScope::Unknown(_))); + + let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); + assert!(matches!(scope3, ParsedScope::Rpc(_))); + + let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app"); + assert!(matches!(scope4, ParsedScope::Rpc(_))); + } +} diff --git a/src/oauth/scopes/permissions.rs b/src/oauth/scopes/permissions.rs new file mode 100644 index 0000000..dac66fb --- /dev/null +++ b/src/oauth/scopes/permissions.rs @@ -0,0 +1,488 @@ +use super::error::ScopeError; +use super::parser::{ + AccountAction, AccountAttr, BlobScope, IdentityAttr, IdentityScope, ParsedScope, RepoAction, + RepoScope, RpcScope, parse_scope_string, +}; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct ScopePermissions { + scopes: HashSet, + parsed: Vec, + has_atproto: bool, + has_transition_generic: bool, + has_transition_chat: bool, + has_transition_email: bool, +} + +impl ScopePermissions { + pub fn from_scope_string(scope: Option<&str>) -> Self { + let scope_str = scope.unwrap_or("atproto"); + let scopes: HashSet = scope_str + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + + let parsed = parse_scope_string(scope_str); + + let has_atproto = parsed.iter().any(|p| matches!(p, ParsedScope::Atproto)); + let has_transition_generic = parsed + .iter() + .any(|p| matches!(p, ParsedScope::TransitionGeneric)); + let has_transition_chat = parsed + .iter() + .any(|p| matches!(p, ParsedScope::TransitionChat)); + let has_transition_email = parsed + .iter() + .any(|p| matches!(p, ParsedScope::TransitionEmail)); + + Self { + scopes, + parsed, + has_atproto, + has_transition_generic, + has_transition_chat, + has_transition_email, + } + } + + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.contains(scope) + } + + pub fn scopes(&self) -> &HashSet { + &self.scopes + } + + pub fn has_full_access(&self) -> bool { + self.has_atproto + } + + fn find_repo_scopes(&self) -> impl Iterator { + self.parsed.iter().filter_map(|p| { + if let ParsedScope::Repo(r) = p { + Some(r) + } else { + None + } + }) + } + + fn find_blob_scopes(&self) -> impl Iterator { + self.parsed.iter().filter_map(|p| { + if let ParsedScope::Blob(b) = p { + Some(b) + } else { + None + } + }) + } + + fn find_rpc_scopes(&self) -> impl Iterator { + self.parsed.iter().filter_map(|p| { + if let ParsedScope::Rpc(r) = p { + Some(r) + } else { + None + } + }) + } + + fn find_account_scopes(&self) -> impl Iterator { + self.parsed.iter().filter_map(|p| { + if let ParsedScope::Account(a) = p { + Some(a) + } else { + None + } + }) + } + + fn find_identity_scopes(&self) -> impl Iterator { + self.parsed.iter().filter_map(|p| { + if let ParsedScope::Identity(i) = p { + Some(i) + } else { + None + } + }) + } + + pub fn assert_repo(&self, action: RepoAction, collection: &str) -> Result<(), ScopeError> { + if self.has_atproto || self.has_transition_generic { + return Ok(()); + } + + for repo_scope in self.find_repo_scopes() { + if !repo_scope.actions.contains(&action) { + continue; + } + + match &repo_scope.collection { + None => return Ok(()), + Some(coll) if coll == collection => return Ok(()), + Some(coll) if coll.ends_with(".*") => { + let prefix = coll.strip_suffix(".*").unwrap(); + if collection.starts_with(prefix) + && collection.chars().nth(prefix.len()) == Some('.') + { + return Ok(()); + } + } + _ => {} + } + } + + Err(ScopeError::InsufficientScope { + required: format!("repo:{}?action={}", collection, action_str(action)), + message: format!( + "Insufficient scope to {} records in {}", + action_str(action), + collection + ), + }) + } + + pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { + if self.has_atproto || self.has_transition_generic { + return Ok(()); + } + + for blob_scope in self.find_blob_scopes() { + if blob_scope.matches_mime(mime) { + return Ok(()); + } + } + + Err(ScopeError::InsufficientScope { + required: format!("blob:{}", mime), + message: format!("Insufficient scope to upload blob with mime type {}", mime), + }) + } + + pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { + if self.has_atproto || self.has_transition_generic { + return Ok(()); + } + + if lxm.starts_with("chat.bsky.") && self.has_transition_chat { + return Ok(()); + } + + for rpc_scope in self.find_rpc_scopes() { + let lxm_matches = match &rpc_scope.lxm { + None => true, + Some(scope_lxm) if scope_lxm == lxm => true, + Some(scope_lxm) if scope_lxm.ends_with(".*") => { + let prefix = scope_lxm.strip_suffix(".*").unwrap(); + lxm.starts_with(prefix) && lxm.chars().nth(prefix.len()) == Some('.') + } + _ => false, + }; + + let aud_matches = match &rpc_scope.aud { + None => true, + Some(scope_aud) if scope_aud == "*" => true, + Some(scope_aud) => scope_aud == aud, + }; + + if lxm_matches && aud_matches { + return Ok(()); + } + } + + Err(ScopeError::InsufficientScope { + required: format!("rpc:{}?aud={}", lxm, aud), + message: format!("Insufficient scope to call {} on {}", lxm, aud), + }) + } + + pub fn assert_account( + &self, + attr: AccountAttr, + action: AccountAction, + ) -> Result<(), ScopeError> { + if self.has_atproto || self.has_transition_generic { + return Ok(()); + } + + if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email + { + return Ok(()); + } + + for account_scope in self.find_account_scopes() { + if account_scope.attr == attr && account_scope.action == action { + return Ok(()); + } + if account_scope.attr == attr && account_scope.action == AccountAction::Manage { + return Ok(()); + } + } + + Err(ScopeError::InsufficientScope { + required: format!( + "account:{}?action={}", + attr_str(attr), + action_str_account(action) + ), + message: format!( + "Insufficient scope to {} account {}", + action_str_account(action), + attr_str(attr) + ), + }) + } + + pub fn allows_email_read(&self) -> bool { + self.has_atproto + || self.has_transition_generic + || self.has_transition_email + || self + .find_account_scopes() + .any(|a| a.attr == AccountAttr::Email) + } + + pub fn allows_repo(&self, action: RepoAction, collection: &str) -> bool { + self.assert_repo(action, collection).is_ok() + } + + pub fn allows_blob(&self, mime: &str) -> bool { + self.assert_blob(mime).is_ok() + } + + pub fn allows_rpc(&self, aud: &str, lxm: &str) -> bool { + self.assert_rpc(aud, lxm).is_ok() + } + + pub fn allows_account(&self, attr: AccountAttr, action: AccountAction) -> bool { + self.assert_account(attr, action).is_ok() + } + + pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> { + if self.has_atproto || self.has_transition_generic { + return Ok(()); + } + + for identity_scope in self.find_identity_scopes() { + if identity_scope.attr == IdentityAttr::Wildcard { + return Ok(()); + } + if identity_scope.attr == attr { + return Ok(()); + } + } + + Err(ScopeError::InsufficientScope { + required: format!("identity:{}", identity_attr_str(attr)), + message: format!( + "Insufficient scope to modify identity {}", + identity_attr_str(attr) + ), + }) + } + + pub fn allows_identity(&self, attr: IdentityAttr) -> bool { + self.assert_identity(attr).is_ok() + } +} + +fn action_str(action: RepoAction) -> &'static str { + match action { + RepoAction::Create => "create", + RepoAction::Update => "update", + RepoAction::Delete => "delete", + } +} + +fn attr_str(attr: AccountAttr) -> &'static str { + match attr { + AccountAttr::Email => "email", + AccountAttr::Handle => "handle", + AccountAttr::Repo => "repo", + AccountAttr::Status => "status", + } +} + +fn identity_attr_str(attr: IdentityAttr) -> &'static str { + match attr { + IdentityAttr::Handle => "handle", + IdentityAttr::Wildcard => "*", + } +} + +fn action_str_account(action: AccountAction) -> &'static str { + match action { + AccountAction::Read => "read", + AccountAction::Manage => "manage", + } +} + +impl Default for ScopePermissions { + fn default() -> Self { + Self::from_scope_string(Some("atproto")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atproto_scope_allows_everything() { + let perms = ScopePermissions::from_scope_string(Some("atproto")); + assert!(perms.has_full_access()); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_blob("image/png")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage)); + } + + #[test] + fn test_transition_generic_allows_everything() { + let perms = ScopePermissions::from_scope_string(Some("transition:generic")); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_blob("image/png")); + } + + #[test] + fn test_transition_chat_only_allows_chat() { + let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky")); + assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + } + + #[test] + fn test_empty_scope_defaults_to_atproto() { + let perms = ScopePermissions::from_scope_string(None); + assert!(perms.has_full_access()); + } + + #[test] + fn test_multiple_scopes() { + let perms = ScopePermissions::from_scope_string(Some("atproto transition:chat.bsky")); + assert!(perms.has_scope("atproto")); + assert!(perms.has_scope("transition:chat.bsky")); + assert!(!perms.has_scope("transition:generic")); + } + + #[test] + fn test_transition_email_allows_email_read() { + let perms = ScopePermissions::from_scope_string(Some("transition:email")); + assert!(perms.allows_email_read()); + assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read)); + assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage)); + assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + } + + #[test] + fn test_granular_repo_wildcard() { + let perms = + ScopePermissions::from_scope_string(Some("atproto repo:*?action=create blob:*/*")); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_repo(RepoAction::Create, "any.collection")); + assert!(perms.allows_blob("image/png")); + } + + #[test] + fn test_granular_repo_collection_specific() { + let perms = ScopePermissions::from_scope_string(Some( + "repo:app.bsky.feed.post?action=create&action=delete", + )); + assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); + assert!(perms.allows_repo(RepoAction::Delete, "app.bsky.feed.post")); + assert!(!perms.allows_repo(RepoAction::Update, "app.bsky.feed.post")); + assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.like")); + } + + #[test] + fn test_granular_blob_specific_mime() { + let perms = ScopePermissions::from_scope_string(Some("blob?accept=image/*&accept=video/*")); + assert!(perms.allows_blob("image/png")); + assert!(perms.allows_blob("image/jpeg")); + assert!(perms.allows_blob("video/mp4")); + assert!(!perms.allows_blob("text/plain")); + assert!(!perms.allows_blob("application/json")); + } + + #[test] + fn test_granular_rpc() { + let perms = ScopePermissions::from_scope_string(Some( + "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app", + )); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); + assert!(!perms.allows_rpc("did:web:other.service", "app.bsky.feed.getTimeline")); + } + + #[test] + fn test_granular_rpc_wildcard_aud() { + let perms = + ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getTimeline?aud=*")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(perms.allows_rpc("did:web:any.service", "app.bsky.feed.getTimeline")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); + } + + #[test] + fn test_granular_account() { + let perms = ScopePermissions::from_scope_string(Some("account:email?action=read")); + assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read)); + assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage)); + assert!(!perms.allows_account(AccountAttr::Handle, AccountAction::Read)); + + let perms2 = ScopePermissions::from_scope_string(Some("account:repo?action=manage")); + assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Manage)); + assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Read)); + } + + #[test] + fn test_granular_scopes_without_atproto() { + let perms = ScopePermissions::from_scope_string(Some("repo:*?action=create")); + assert!(!perms.has_full_access()); + assert!(perms.allows_repo(RepoAction::Create, "any.collection")); + assert!(!perms.allows_repo(RepoAction::Update, "any.collection")); + assert!(!perms.allows_repo(RepoAction::Delete, "any.collection")); + } + + #[test] + fn test_pdsls_style_scopes() { + let perms = ScopePermissions::from_scope_string(Some( + "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", + )); + assert!(perms.allows_repo(RepoAction::Create, "any.collection")); + assert!(perms.allows_repo(RepoAction::Update, "any.collection")); + assert!(perms.allows_repo(RepoAction::Delete, "any.collection")); + assert!(perms.allows_blob("image/png")); + assert!(perms.allows_blob("video/mp4")); + } + + #[test] + fn test_identity_scope_handle() { + let perms = ScopePermissions::from_scope_string(Some("identity:handle")); + assert!(perms.allows_identity(IdentityAttr::Handle)); + assert!(!perms.allows_identity(IdentityAttr::Wildcard)); + } + + #[test] + fn test_identity_scope_wildcard() { + let perms = ScopePermissions::from_scope_string(Some("identity:*")); + assert!(perms.allows_identity(IdentityAttr::Handle)); + assert!(perms.allows_identity(IdentityAttr::Wildcard)); + } + + #[test] + fn test_identity_scope_with_atproto() { + let perms = ScopePermissions::from_scope_string(Some("atproto")); + assert!(perms.allows_identity(IdentityAttr::Handle)); + assert!(perms.allows_identity(IdentityAttr::Wildcard)); + } + + #[test] + fn test_account_status_scope() { + let perms = ScopePermissions::from_scope_string(Some("account:status?action=read")); + assert!(perms.allows_account(AccountAttr::Status, AccountAction::Read)); + assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage)); + } +} diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs deleted file mode 100644 index d3ecb61..0000000 --- a/src/oauth/templates.rs +++ /dev/null @@ -1,595 +0,0 @@ -use chrono::{DateTime, Utc}; - -fn format_scope_for_display(scope: Option<&str>) -> String { - let scope = scope.unwrap_or(""); - if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") { - return "access your account".to_string(); - } - let parts: Vec<&str> = scope.split_whitespace().collect(); - let friendly: Vec<&str> = parts - .iter() - .filter_map(|s| { - match *s { - "atproto" | "transition:generic" | "transition:chat.bsky" => None, - "read" => Some("read your data"), - "write" => Some("write data"), - other => Some(other), - } - }) - .collect(); - if friendly.is_empty() { - "access your account".to_string() - } else { - friendly.join(", ") - } -} - -fn base_styles() -> &'static str { - r#" - :root { - --bg-primary: #fafafa; - --bg-secondary: #f9f9f9; - --bg-card: #ffffff; - --bg-input: #ffffff; - --text-primary: #333333; - --text-secondary: #666666; - --text-muted: #999999; - --border-color: #dddddd; - --border-color-light: #cccccc; - --accent: #0066cc; - --accent-hover: #0052a3; - --success-bg: #dfd; - --success-border: #8c8; - --success-text: #060; - --error-bg: #fee; - --error-border: #fcc; - --error-text: #c00; - } - @media (prefers-color-scheme: dark) { - :root { - --bg-primary: #1a1a1a; - --bg-secondary: #242424; - --bg-card: #2a2a2a; - --bg-input: #333333; - --text-primary: #e0e0e0; - --text-secondary: #a0a0a0; - --text-muted: #707070; - --border-color: #404040; - --border-color-light: #505050; - --accent: #4da6ff; - --accent-hover: #7abbff; - --success-bg: #1a3d1a; - --success-border: #2d5a2d; - --success-text: #7bc67b; - --error-bg: #3d1a1a; - --error-border: #5a2d2d; - --error-text: #ff7b7b; - } - } - * { - box-sizing: border-box; - margin: 0; - padding: 0; - } - body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - line-height: 1.5; - } - .container { - max-width: 400px; - margin: 4rem auto; - padding: 2rem; - } - h1 { - margin: 0 0 0.5rem 0; - font-weight: 600; - } - .subtitle { - color: var(--text-secondary); - margin: 0 0 2rem 0; - } - .subtitle strong { - color: var(--text-primary); - } - .client-info { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 1rem; - margin-bottom: 1.5rem; - } - .client-info .client-name { - font-weight: 500; - color: var(--text-primary); - display: block; - margin-bottom: 0.25rem; - } - .client-info .scope { - color: var(--text-secondary); - font-size: 0.875rem; - } - .error-banner { - background: var(--error-bg); - border: 1px solid var(--error-border); - color: var(--error-text); - border-radius: 4px; - padding: 0.75rem; - margin-bottom: 1rem; - } - .form-group { - margin-bottom: 1rem; - } - label { - display: block; - font-size: 0.875rem; - font-weight: 500; - margin-bottom: 0.25rem; - } - input[type="text"], - input[type="email"], - input[type="password"] { - width: 100%; - padding: 0.75rem; - border: 1px solid var(--border-color-light); - border-radius: 4px; - font-size: 1rem; - color: var(--text-primary); - background: var(--bg-input); - } - input[type="text"]:focus, - input[type="email"]:focus, - input[type="password"]:focus { - outline: none; - border-color: var(--accent); - } - input[type="text"]::placeholder, - input[type="email"]::placeholder, - input[type="password"]::placeholder { - color: var(--text-muted); - } - .checkbox-group { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 1.5rem; - } - .checkbox-group input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--accent); - } - .checkbox-group label { - margin-bottom: 0; - font-weight: normal; - color: var(--text-secondary); - cursor: pointer; - } - .buttons { - display: flex; - gap: 0.75rem; - } - .btn { - flex: 1; - padding: 0.75rem; - border-radius: 4px; - font-size: 1rem; - cursor: pointer; - border: none; - text-align: center; - text-decoration: none; - } - .btn-primary { - background: var(--accent); - color: white; - } - .btn-primary:hover { - background: var(--accent-hover); - } - .btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; - } - .btn-secondary { - background: transparent; - color: var(--accent); - border: 1px solid var(--accent); - } - .btn-secondary:hover { - background: var(--accent); - color: white; - } - .footer { - text-align: center; - margin-top: 1.5rem; - font-size: 0.75rem; - color: var(--text-muted); - } - .accounts { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; - } - .account-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 1rem; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s; - text-align: left; - } - .account-item:hover { - border-color: var(--accent); - box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); - } - .account-info { - display: flex; - flex-direction: column; - gap: 0.25rem; - flex: 1; - min-width: 0; - } - .account-info .handle { - font-weight: 500; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .account-info .did { - font-size: 0.75rem; - color: var(--text-muted); - font-family: monospace; - overflow: hidden; - text-overflow: ellipsis; - } - .chevron { - color: var(--text-muted); - font-size: 1.25rem; - flex-shrink: 0; - margin-left: 0.5rem; - } - .divider { - height: 1px; - background: var(--border-color); - margin: 1rem 0; - } - .new-account-link { - display: block; - text-align: center; - color: var(--accent); - text-decoration: none; - font-size: 0.875rem; - } - .new-account-link:hover { - text-decoration: underline; - } - .help-text { - text-align: center; - margin-top: 1rem; - font-size: 0.875rem; - color: var(--text-secondary); - } - .icon { - font-size: 3rem; - margin-bottom: 1rem; - } - .error-code { - background: var(--error-bg); - border: 1px solid var(--error-border); - color: var(--error-text); - padding: 0.5rem 1rem; - border-radius: 4px; - font-family: monospace; - display: inline-block; - margin-bottom: 1rem; - } - .success-icon { - width: 3rem; - height: 3rem; - border-radius: 50%; - background: var(--success-bg); - border: 1px solid var(--success-border); - color: var(--success-text); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - margin: 0 auto 1rem; - } - .text-center { - text-align: center; - } - .code-input { - letter-spacing: 0.5em; - text-align: center; - font-size: 1.5rem; - font-family: monospace; - } - "# -} - -pub fn login_page( - client_id: &str, - client_name: Option<&str>, - scope: Option<&str>, - request_uri: &str, - error_message: Option<&str>, - login_hint: Option<&str>, -) -> String { - let client_display = client_name.unwrap_or(client_id); - let scope_display = format_scope_for_display(scope); - let error_html = error_message - .map(|msg| format!(r#"
{}
"#, html_escape(msg))) - .unwrap_or_default(); - let login_hint_value = login_hint.unwrap_or(""); - format!( - r#" - - - - - - Sign in - - - -
-

Sign In

-

Sign in to continue to {client_display}

-
- {client_display} - wants to {scope_display} -
- {error_html} -
- -
- - -
-
- - -
-
- - -
-
- - -
-
-

- By signing in, you agree to share your account information with this application. -

-
- -"#, - styles = base_styles(), - client_display = html_escape(client_display), - scope_display = html_escape(&scope_display), - request_uri = html_escape(request_uri), - error_html = error_html, - login_hint_value = html_escape(login_hint_value), - ) -} - -pub struct DeviceAccount { - pub did: String, - pub handle: String, - pub email: Option, - pub last_used_at: DateTime, -} - -pub fn account_selector_page( - client_id: &str, - client_name: Option<&str>, - request_uri: &str, - accounts: &[DeviceAccount], -) -> String { - let client_display = client_name.unwrap_or(client_id); - let accounts_html: String = accounts - .iter() - .map(|account| { - format!( - r#"
- - - -
"#, - request_uri = html_escape(request_uri), - did = html_escape(&account.did), - handle = html_escape(&account.handle), - ) - }) - .collect(); - format!( - r#" - - - - - - Choose an account - - - -
-

Sign In

-

Choose an account to continue to {client_display}

-
- {accounts_html} -
-
- -
- -"#, - styles = base_styles(), - client_display = html_escape(client_display), - accounts_html = accounts_html, - request_uri_encoded = urlencoding::encode(request_uri), - ) -} - -pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String { - let error_html = error_message - .map(|msg| format!(r#"
{}
"#, html_escape(msg))) - .unwrap_or_default(); - let (title, subtitle) = match channel { - "email" => ( - "Check Your Email", - "We sent a verification code to your email", - ), - "Discord" => ( - "Check Discord", - "We sent a verification code to your Discord", - ), - "Telegram" => ( - "Check Telegram", - "We sent a verification code to your Telegram", - ), - "Signal" => ("Check Signal", "We sent a verification code to your Signal"), - _ => ("Check Your Messages", "We sent you a verification code"), - }; - format!( - r#" - - - - - - Verify your identity - - - -
-

{title}

-

{subtitle}

- {error_html} -
- -
- - -
- -
-

- Code expires in 10 minutes. -

-
- -"#, - styles = base_styles(), - title = title, - subtitle = subtitle, - request_uri = html_escape(request_uri), - error_html = error_html, - ) -} - -pub fn error_page(error: &str, error_description: Option<&str>) -> String { - let description = - error_description.unwrap_or("An error occurred during the authorization process."); - format!( - r#" - - - - - - Authorization Error - - - -
-

Authorization Failed

-
{error}
-

{description}

-
- -
-
- -"#, - styles = base_styles(), - error = html_escape(error), - description = html_escape(description), - ) -} - -pub fn success_page(client_name: Option<&str>) -> String { - let client_display = client_name.unwrap_or("The application"); - format!( - r#" - - - - - - Authorization Successful - - - -
-
-

Authorization Successful

-

{client_display} has been granted access to your account.

-

You can close this window and return to the application.

-
- -"#, - styles = base_styles(), - client_display = html_escape(client_display), - ) -} - -fn html_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -pub fn mask_email(email: &str) -> String { - if let Some(at_pos) = email.find('@') { - let local = &email[..at_pos]; - let domain = &email[at_pos..]; - if local.len() <= 2 { - format!("{}***{}", local.chars().next().unwrap_or('*'), domain) - } else { - let first = local.chars().next().unwrap_or('*'); - let last = local.chars().last().unwrap_or('*'); - format!("{}***{}{}", first, last, domain) - } - } else { - "***".to_string() - } -} diff --git a/src/oauth/types.rs b/src/oauth/types.rs index 608f283..6fb279a 100644 --- a/src/oauth/types.rs +++ b/src/oauth/types.rs @@ -91,6 +91,7 @@ pub struct AuthorizationRequestParameters { pub state: Option, pub code_challenge: String, pub code_challenge_method: String, + pub response_mode: Option, pub login_hint: Option, pub dpop_jkt: Option, #[serde(flatten)] diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs index ca7333c..010b8e5 100644 --- a/src/oauth/verify.rs +++ b/src/oauth/verify.rs @@ -14,6 +14,7 @@ use subtle::ConstantTimeEq; use super::OAuthError; use super::db; use super::dpop::DPoPVerifier; +use super::scopes::ScopePermissions; use crate::config::AuthConfig; use crate::state::AppState; @@ -175,6 +176,7 @@ pub struct OAuthUser { pub client_id: Option, pub scope: Option, pub is_oauth: bool, + pub permissions: ScopePermissions, } pub struct OAuthAuthError { @@ -244,18 +246,23 @@ impl FromRequestParts for OAuthUser { client_id: None, scope: None, is_oauth: false, + permissions: ScopePermissions::default(), }); } let http_method = parts.method.as_str(); let http_uri = parts.uri.to_string(); match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await { - Ok(result) => Ok(OAuthUser { - did: result.did, - client_id: Some(result.client_id), - scope: result.scope, - is_oauth: true, - }), + Ok(result) => { + let permissions = ScopePermissions::from_scope_string(result.scope.as_deref()); + Ok(OAuthUser { + did: result.did, + client_id: Some(result.client_id), + scope: result.scope, + is_oauth: true, + permissions, + }) + } Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError { status: StatusCode::UNAUTHORIZED, error: "use_dpop_nonce".to_string(), diff --git a/src/plc/mod.rs b/src/plc/mod.rs index 9e8e863..556ffd4 100644 --- a/src/plc/mod.rs +++ b/src/plc/mod.rs @@ -408,11 +408,12 @@ pub fn validate_plc_operation_for_submission( PlcError::InvalidResponse("verificationMethods must be an object".to_string()) })?; if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) - && atproto_key != ctx.expected_signing_key { - return Err(PlcError::InvalidResponse( - "Incorrect signing key".to_string(), - )); - } + && atproto_key != ctx.expected_signing_key + { + return Err(PlcError::InvalidResponse( + "Incorrect signing key".to_string(), + )); + } let also_known_as = obj .get("alsoKnownAs") .and_then(|v| v.as_array()) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index c55d6fe..ee33978 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -122,14 +122,16 @@ 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(); - } + && 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(); - } + && 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()) diff --git a/src/sync/import.rs b/src/sync/import.rs index f26f86e..fa13fac 100644 --- a/src/sync/import.rs +++ b/src/sync/import.rs @@ -77,19 +77,20 @@ pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec { Ipld::Map(obj) => { if let Some(Ipld::String(type_str)) = obj.get("$type") && type_str == "blob" - && let Some(Ipld::Link(link_cid)) = obj.get("ref") { - let mime = obj.get("mimeType").and_then(|v| { - if let Ipld::String(s) = v { - Some(s.clone()) - } else { - None - } - }); - return vec![BlobRef { - cid: link_cid.to_string(), - mime_type: mime, - }]; + && let Some(Ipld::Link(link_cid)) = obj.get("ref") + { + let mime = obj.get("mimeType").and_then(|v| { + if let Ipld::String(s) = v { + Some(s.clone()) + } else { + None } + }); + return vec![BlobRef { + cid: link_cid.to_string(), + mime_type: mime, + }]; + } obj.values() .flat_map(|v| find_blob_refs_ipld(v, depth + 1)) .collect() @@ -110,17 +111,18 @@ pub fn find_blob_refs(value: &JsonValue, depth: usize) -> Vec { JsonValue::Object(obj) => { if let Some(JsonValue::String(type_str)) = obj.get("$type") && type_str == "blob" - && let Some(JsonValue::Object(ref_obj)) = obj.get("ref") - && let Some(JsonValue::String(link)) = ref_obj.get("$link") { - let mime = obj - .get("mimeType") - .and_then(|v| v.as_str()) - .map(String::from); - return vec![BlobRef { - cid: link.clone(), - mime_type: mime, - }]; - } + && let Some(JsonValue::Object(ref_obj)) = obj.get("ref") + && let Some(JsonValue::String(link)) = ref_obj.get("$link") + { + let mime = obj + .get("mimeType") + .and_then(|v| v.as_str()) + .map(String::from); + return vec![BlobRef { + cid: link.clone(), + mime_type: mime, + }]; + } obj.values() .flat_map(|v| find_blob_refs(v, depth + 1)) .collect() @@ -195,22 +197,22 @@ pub fn walk_mst( }); if let (Some(key), Some(record_cid)) = (key, record_cid) && let Some(record_block) = blocks.get(&record_cid) - && let Ok(record_value) = - serde_ipld_dagcbor::from_slice::(record_block) - { - let blob_refs = find_blob_refs_ipld(&record_value, 0); - let parts: Vec<&str> = key.split('/').collect(); - if parts.len() >= 2 { - let collection = parts[..parts.len() - 1].join("/"); - let rkey = parts[parts.len() - 1].to_string(); - records.push(ImportedRecord { - collection, - rkey, - cid: record_cid, - blob_refs, - }); - } - } + && let Ok(record_value) = + serde_ipld_dagcbor::from_slice::(record_block) + { + let blob_refs = find_blob_refs_ipld(&record_value, 0); + let parts: Vec<&str> = key.split('/').collect(); + if parts.len() >= 2 { + let collection = parts[..parts.len() - 1].join("/"); + let rkey = parts[parts.len() - 1].to_string(); + records.push(ImportedRecord { + collection, + rkey, + cid: record_cid, + blob_refs, + }); + } + } if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { stack.push(*tree_cid); } @@ -300,9 +302,10 @@ pub async fn apply_import( .await .map_err(|e| { if let sqlx::Error::Database(ref db_err) = e - && db_err.code().as_deref() == Some("55P03") { - return ImportError::ConcurrentModification; - } + && db_err.code().as_deref() == Some("55P03") + { + return ImportError::ConcurrentModification; + } ImportError::Database(e) })?; if repo.is_none() { diff --git a/src/sync/util.rs b/src/sync/util.rs index a93c78f..34cb7de 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -140,9 +140,10 @@ pub async fn format_event_for_sending( .try_into() .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; if let Some(ref pdc) = prev_data_cid_str - && let Ok(cid) = Cid::from_str(pdc) { - frame.prev_data = Some(cid); - } + && let Ok(cid) = Cid::from_str(pdc) + { + frame.prev_data = Some(cid); + } let commit_cid = frame.commit; let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok()); let mut all_cids: Vec = block_cids_str @@ -155,9 +156,10 @@ pub async fn format_event_for_sending( } if let Some(ref pc) = prev_cid && let Ok(Some(prev_bytes)) = state.block_store.get(pc).await - && let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) { - frame.since = Some(rev); - } + && let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) + { + frame.since = Some(rev); + } let car_bytes = if !all_cids.is_empty() { let fetched = state.block_store.get_many(&all_cids).await?; let mut blocks = std::collections::BTreeMap::new(); @@ -196,13 +198,15 @@ pub async fn prefetch_blocks_for_events( let mut all_cids: Vec = Vec::new(); for event in events { if let Some(ref commit_cid_str) = event.commit_cid - && let Ok(cid) = Cid::from_str(commit_cid_str) { - all_cids.push(cid); - } + && let Ok(cid) = Cid::from_str(commit_cid_str) + { + all_cids.push(cid); + } if let Some(ref prev_cid_str) = event.prev_cid - && let Ok(cid) = Cid::from_str(prev_cid_str) { - all_cids.push(cid); - } + && let Ok(cid) = Cid::from_str(prev_cid_str) + { + all_cids.push(cid); + } if let Some(ref block_cids_str) = event.blocks_cids { for s in block_cids_str { if let Ok(cid) = Cid::from_str(s) { @@ -279,9 +283,10 @@ pub async fn format_event_with_prefetched_blocks( .try_into() .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; if let Some(ref pdc) = prev_data_cid_str - && let Ok(cid) = Cid::from_str(pdc) { - frame.prev_data = Some(cid); - } + && let Ok(cid) = Cid::from_str(pdc) + { + frame.prev_data = Some(cid); + } let commit_cid = frame.commit; let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok()); let mut all_cids: Vec = block_cids_str @@ -293,14 +298,16 @@ pub async fn format_event_with_prefetched_blocks( all_cids.push(commit_cid); } if let Some(commit_bytes) = prefetched.get(&commit_cid) - && let Some(rev) = extract_rev_from_commit_bytes(commit_bytes) { - frame.rev = rev; - } + && let Some(rev) = extract_rev_from_commit_bytes(commit_bytes) + { + frame.rev = rev; + } if let Some(ref pc) = prev_cid && let Some(prev_bytes) = prefetched.get(pc) - && let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) { - frame.since = Some(rev); - } + && let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) + { + frame.since = Some(rev); + } let car_bytes = if !all_cids.is_empty() { let mut blocks = BTreeMap::new(); let mut commit_bytes_for_car: Option = None; diff --git a/src/sync/verify.rs b/src/sync/verify.rs index c90908c..6832ead 100644 --- a/src/sync/verify.rs +++ b/src/sync/verify.rs @@ -268,12 +268,13 @@ impl CarVerifier { stack.push(*tree_cid); } if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") - && !blocks.contains_key(value_cid) { - warn!( - "Record block {} referenced in MST not in CAR (may be expected for partial export)", - value_cid - ); - } + && !blocks.contains_key(value_cid) + { + warn!( + "Record block {} referenced in MST not in CAR (may be expected for partial export)", + value_cid + ); + } } } } diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 000d32b..c51f80c 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -111,12 +111,13 @@ impl RecordValidator { } } if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) - && langs.len() > 3 { - return Err(ValidationError::InvalidField { - path: "langs".to_string(), - message: "Maximum 3 languages allowed".to_string(), - }); - } + && langs.len() > 3 + { + return Err(ValidationError::InvalidField { + path: "langs".to_string(), + message: "Maximum 3 languages allowed".to_string(), + }); + } if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { if tags.len() > 8 { return Err(ValidationError::InvalidField { @@ -126,12 +127,13 @@ impl RecordValidator { } for (i, tag) in tags.iter().enumerate() { if let Some(tag_str) = tag.as_str() - && tag_str.len() > 640 { - return Err(ValidationError::InvalidField { - path: format!("tags/{}", i), - message: "Tag exceeds maximum length of 640 bytes".to_string(), - }); - } + && tag_str.len() > 640 + { + return Err(ValidationError::InvalidField { + path: format!("tags/{}", i), + message: "Tag exceeds maximum length of 640 bytes".to_string(), + }); + } } } Ok(()) @@ -198,12 +200,13 @@ impl RecordValidator { return Err(ValidationError::MissingField("createdAt".to_string())); } if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) - && !subject.starts_with("did:") { - return Err(ValidationError::InvalidField { - path: "subject".to_string(), - message: "Subject must be a DID".to_string(), - }); - } + && !subject.starts_with("did:") + { + return Err(ValidationError::InvalidField { + path: "subject".to_string(), + message: "Subject must be a DID".to_string(), + }); + } Ok(()) } @@ -215,12 +218,13 @@ impl RecordValidator { return Err(ValidationError::MissingField("createdAt".to_string())); } if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) - && !subject.starts_with("did:") { - return Err(ValidationError::InvalidField { - path: "subject".to_string(), - message: "Subject must be a DID".to_string(), - }); - } + && !subject.starts_with("did:") + { + return Err(ValidationError::InvalidField { + path: "subject".to_string(), + message: "Subject must be a DID".to_string(), + }); + } Ok(()) } @@ -235,12 +239,13 @@ impl RecordValidator { return Err(ValidationError::MissingField("createdAt".to_string())); } if let Some(name) = obj.get("name").and_then(|v| v.as_str()) - && (name.is_empty() || name.len() > 64) { - return Err(ValidationError::InvalidField { - path: "name".to_string(), - message: "Name must be 1-64 characters".to_string(), - }); - } + && (name.is_empty() || name.len() > 64) + { + return Err(ValidationError::InvalidField { + path: "name".to_string(), + message: "Name must be 1-64 characters".to_string(), + }); + } Ok(()) } @@ -274,12 +279,13 @@ impl RecordValidator { return Err(ValidationError::MissingField("createdAt".to_string())); } if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) - && (display_name.is_empty() || display_name.len() > 240) { - return Err(ValidationError::InvalidField { - path: "displayName".to_string(), - message: "displayName must be 1-240 characters".to_string(), - }); - } + && (display_name.is_empty() || display_name.len() > 240) + { + return Err(ValidationError::InvalidField { + path: "displayName".to_string(), + message: "displayName must be 1-240 characters".to_string(), + }); + } Ok(()) } @@ -328,12 +334,13 @@ impl RecordValidator { return Err(ValidationError::MissingField(format!("{}/cid", path))); } if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) - && !uri.starts_with("at://") { - return Err(ValidationError::InvalidField { - path: format!("{}/uri", path), - message: "URI must be an at:// URI".to_string(), - }); - } + && !uri.starts_with("at://") + { + return Err(ValidationError::InvalidField { + path: format!("{}/uri", path), + message: "URI must be an at:// URI".to_string(), + }); + } Ok(()) } } diff --git a/tests/account_notifications.rs b/tests/account_notifications.rs index 758d4ff..87ba309 100644 --- a/tests/account_notifications.rs +++ b/tests/account_notifications.rs @@ -1,8 +1,8 @@ mod common; use common::{base_url, client, create_account_and_login, get_db_connection_string}; -use tranquil_pds::comms::{NewComms, CommsType, enqueue_comms}; use serde_json::{Value, json}; use sqlx::PgPool; +use tranquil_pds::comms::{CommsType, NewComms, enqueue_comms}; async fn get_pool() -> PgPool { let conn_str = get_db_connection_string().await; @@ -33,11 +33,16 @@ async fn test_get_notification_history() { format!("Subject {}", i), format!("Body {}", i), ); - enqueue_comms(&pool, comms).await.expect("Failed to enqueue"); + enqueue_comms(&pool, comms) + .await + .expect("Failed to enqueue"); } let resp = client - .get(format!("{}/xrpc/com.tranquil.account.getNotificationHistory", base)) + .get(format!( + "{}/xrpc/com.tranquil.account.getNotificationHistory", + base + )) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -63,7 +68,10 @@ async fn test_verify_channel_discord() { "discordId": "123456789" }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&prefs) .send() @@ -71,7 +79,12 @@ async fn test_verify_channel_discord() { .unwrap(); assert_eq!(resp.status(), 200); let body: Value = resp.json().await.unwrap(); - assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("discord"))); + assert!( + body["verificationRequired"] + .as_array() + .unwrap() + .contains(&json!("discord")) + ); let pool = get_pool().await; let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) @@ -92,7 +105,10 @@ async fn test_verify_channel_discord() { "code": code }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.confirmChannelVerification", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&input) .send() @@ -101,7 +117,10 @@ async fn test_verify_channel_discord() { assert_eq!(resp.status(), 200); let resp = client - .get(format!("{}/xrpc/com.tranquil.account.getNotificationPrefs", base)) + .get(format!( + "{}/xrpc/com.tranquil.account.getNotificationPrefs", + base + )) .header("Authorization", format!("Bearer {}", token)) .send() .await @@ -121,7 +140,10 @@ async fn test_verify_channel_invalid_code() { "telegramUsername": "testuser" }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&prefs) .send() @@ -134,7 +156,10 @@ async fn test_verify_channel_invalid_code() { "code": "000000" }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.confirmChannelVerification", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&input) .send() @@ -154,7 +179,10 @@ async fn test_verify_channel_not_set() { "code": "123456" }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.confirmChannelVerification", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&input) .send() @@ -175,7 +203,10 @@ async fn test_update_email_via_notification_prefs() { "email": unique_email }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&prefs) .send() @@ -183,7 +214,12 @@ async fn test_update_email_via_notification_prefs() { .unwrap(); assert_eq!(resp.status(), 200); let body: Value = resp.json().await.unwrap(); - assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("email"))); + assert!( + body["verificationRequired"] + .as_array() + .unwrap() + .contains(&json!("email")) + ); let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_one(&pool) @@ -203,7 +239,10 @@ async fn test_update_email_via_notification_prefs() { "code": code }); let resp = client - .post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base)) + .post(format!( + "{}/xrpc/com.tranquil.account.confirmChannelVerification", + base + )) .header("Authorization", format!("Bearer {}", token)) .json(&input) .send() @@ -212,7 +251,10 @@ async fn test_update_email_via_notification_prefs() { assert_eq!(resp.status(), 200); let resp = client - .get(format!("{}/xrpc/com.tranquil.account.getNotificationPrefs", base)) + .get(format!( + "{}/xrpc/com.tranquil.account.getNotificationPrefs", + base + )) .header("Authorization", format!("Bearer {}", token)) .send() .await diff --git a/tests/admin_search.rs b/tests/admin_search.rs index 92da44e..a0b30c1 100644 --- a/tests/admin_search.rs +++ b/tests/admin_search.rs @@ -21,10 +21,18 @@ async fn test_search_accounts_as_admin() { .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); - let accounts = body["accounts"].as_array().expect("accounts should be array"); + let accounts = body["accounts"] + .as_array() + .expect("accounts should be array"); assert!(!accounts.is_empty(), "Should return some accounts"); - let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did)); - assert!(found, "Should find the created user in results (DID: {})", user_did); + let found = accounts + .iter() + .any(|a| a["did"].as_str() == Some(&user_did)); + assert!( + found, + "Should find the created user in results (DID: {})", + user_did + ); } #[tokio::test] @@ -61,7 +69,11 @@ async fn test_search_accounts_with_handle_filter() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let accounts = body["accounts"].as_array().unwrap(); - assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle"); + assert_eq!( + accounts.len(), + 1, + "Should find exactly one account with this handle" + ); assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str())); } @@ -100,11 +112,23 @@ async fn test_search_accounts_pagination() { assert_eq!(res2.status(), StatusCode::OK); let body2: Value = res2.json().await.unwrap(); let accounts2 = body2["accounts"].as_array().unwrap(); - assert!(!accounts2.is_empty(), "Should return more accounts after cursor"); - let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect(); - let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect(); + assert!( + !accounts2.is_empty(), + "Should return more accounts after cursor" + ); + let first_page_dids: Vec<&str> = accounts + .iter() + .map(|a| a["did"].as_str().unwrap()) + .collect(); + let second_page_dids: Vec<&str> = accounts2 + .iter() + .map(|a| a["did"].as_str().unwrap()) + .collect(); for did in &second_page_dids { - assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs"); + assert!( + !first_page_dids.contains(did), + "Second page should not repeat first page DIDs" + ); } } @@ -160,5 +184,8 @@ async fn test_search_accounts_returns_expected_fields() { let account = &accounts[0]; assert!(account["did"].as_str().is_some(), "Should have did"); assert!(account["handle"].as_str().is_some(), "Should have handle"); - assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt"); + assert!( + account["indexedAt"].as_str().is_some(), + "Should have indexedAt" + ); } diff --git a/tests/admin_stats.rs b/tests/admin_stats.rs index 1e58658..e219a2e 100644 --- a/tests/admin_stats.rs +++ b/tests/admin_stats.rs @@ -38,4 +38,4 @@ async fn test_get_server_stats_no_auth() { .await .unwrap(); assert_eq!(resp.status(), 401); -} \ No newline at end of file +} diff --git a/tests/change_password.rs b/tests/change_password.rs index 77d2957..c19bd1a 100644 --- a/tests/change_password.rs +++ b/tests/change_password.rs @@ -57,7 +57,11 @@ async fn test_change_password_success() { .send() .await .expect("Failed to try old password"); - assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work"); + assert_eq!( + login_old.status(), + StatusCode::UNAUTHORIZED, + "Old password should not work" + ); let login_new = client .post(format!( "{}/xrpc/com.atproto.server.createSession", @@ -70,7 +74,11 @@ async fn test_change_password_success() { .send() .await .expect("Failed to try new password"); - assert_eq!(login_new.status(), StatusCode::OK, "New password should work"); + assert_eq!( + login_new.status(), + StatusCode::OK, + "New password should work" + ); } #[tokio::test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 90c1dbb..8d4ad4d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,7 +1,6 @@ use aws_config::BehaviorVersion; use aws_sdk_s3::Client as S3Client; use aws_sdk_s3::config::Credentials; -use tranquil_pds::state::AppState; use chrono::Utc; use reqwest::{Client, StatusCode, header}; use serde_json::{Value, json}; @@ -12,6 +11,7 @@ use std::sync::OnceLock; #[allow(unused_imports)] use std::time::Duration; use tokio::net::TcpListener; +use tranquil_pds::state::AppState; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -232,8 +232,7 @@ async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_en .await; } -async fn setup_mock_appview(_mock_server: &MockServer) { -} +async fn setup_mock_appview(_mock_server: &MockServer) {} async fn spawn_app(database_url: String) -> String { use tranquil_pds::rate_limit::RateLimiters; diff --git a/tests/email_update.rs b/tests/email_update.rs index dd4ddfb..99c6c42 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -84,13 +84,10 @@ async fn test_email_update_flow_success() { .await .expect("Failed to confirm email"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!( - "SELECT email FROM users WHERE handle = $1", - handle - ) - .fetch_one(&pool) - .await - .expect("User not found"); + let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) + .fetch_one(&pool) + .await + .expect("User not found"); assert_eq!(user.email, Some(new_email)); let verification = sqlx::query!( @@ -320,13 +317,10 @@ async fn test_update_email_with_valid_token() { .await .expect("Failed to update email"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!( - "SELECT email FROM users WHERE handle = $1", - handle - ) - .fetch_one(&pool) - .await - .expect("User not found"); + let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) + .fetch_one(&pool) + .await + .expect("User not found"); assert_eq!(user.email, Some(new_email)); let verification = sqlx::query!( "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", diff --git a/tests/image_processing.rs b/tests/image_processing.rs index e876085..ed0f97f 100644 --- a/tests/image_processing.rs +++ b/tests/image_processing.rs @@ -1,35 +1,39 @@ +use image::{DynamicImage, ImageFormat}; +use std::io::Cursor; use tranquil_pds::image::{ DEFAULT_MAX_FILE_SIZE, ImageError, ImageProcessor, OutputFormat, THUMB_SIZE_FEED, THUMB_SIZE_FULL, }; -use image::{DynamicImage, ImageFormat}; -use std::io::Cursor; fn create_test_png(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) + .unwrap(); buf } fn create_test_jpeg(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg) + .unwrap(); buf } fn create_test_gif(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif) + .unwrap(); buf } fn create_test_webp(width: u32, height: u32) -> Vec { let img = DynamicImage::new_rgb8(width, height); let mut buf = Vec::new(); - img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP).unwrap(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP) + .unwrap(); buf } @@ -62,18 +66,36 @@ fn test_thumbnail_generation() { let small = create_test_png(100, 100); let result = processor.process(&small, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_none(), "Small image should not get feed thumbnail"); - assert!(result.thumbnail_full.is_none(), "Small image should not get full thumbnail"); + assert!( + result.thumbnail_feed.is_none(), + "Small image should not get feed thumbnail" + ); + assert!( + result.thumbnail_full.is_none(), + "Small image should not get full thumbnail" + ); let medium = create_test_png(500, 500); let result = processor.process(&medium, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_some(), "Medium image should have feed thumbnail"); - assert!(result.thumbnail_full.is_none(), "Medium image should NOT have full thumbnail"); + assert!( + result.thumbnail_feed.is_some(), + "Medium image should have feed thumbnail" + ); + assert!( + result.thumbnail_full.is_none(), + "Medium image should NOT have full thumbnail" + ); let large = create_test_png(2000, 2000); let result = processor.process(&large, "image/png").unwrap(); - assert!(result.thumbnail_feed.is_some(), "Large image should have feed thumbnail"); - assert!(result.thumbnail_full.is_some(), "Large image should have full thumbnail"); + assert!( + result.thumbnail_feed.is_some(), + "Large image should have feed thumbnail" + ); + assert!( + result.thumbnail_full.is_some(), + "Large image should have full thumbnail" + ); let thumb = result.thumbnail_feed.unwrap(); assert!(thumb.width <= THUMB_SIZE_FEED && thumb.height <= THUMB_SIZE_FEED); let full = result.thumbnail_full.unwrap(); @@ -81,13 +103,37 @@ fn test_thumbnail_generation() { let at_feed = create_test_png(THUMB_SIZE_FEED, THUMB_SIZE_FEED); let above_feed = create_test_png(THUMB_SIZE_FEED + 1, THUMB_SIZE_FEED + 1); - assert!(processor.process(&at_feed, "image/png").unwrap().thumbnail_feed.is_none()); - assert!(processor.process(&above_feed, "image/png").unwrap().thumbnail_feed.is_some()); + assert!( + processor + .process(&at_feed, "image/png") + .unwrap() + .thumbnail_feed + .is_none() + ); + assert!( + processor + .process(&above_feed, "image/png") + .unwrap() + .thumbnail_feed + .is_some() + ); let at_full = create_test_png(THUMB_SIZE_FULL, THUMB_SIZE_FULL); let above_full = create_test_png(THUMB_SIZE_FULL + 1, THUMB_SIZE_FULL + 1); - assert!(processor.process(&at_full, "image/png").unwrap().thumbnail_full.is_none()); - assert!(processor.process(&above_full, "image/png").unwrap().thumbnail_full.is_some()); + assert!( + processor + .process(&at_full, "image/png") + .unwrap() + .thumbnail_full + .is_none() + ); + assert!( + processor + .process(&above_full, "image/png") + .unwrap() + .thumbnail_full + .is_some() + ); let disabled = ImageProcessor::new().with_thumbnails(false); let result = disabled.process(&large, "image/png").unwrap(); @@ -100,13 +146,34 @@ fn test_output_format_conversion() { let jpeg = create_test_jpeg(300, 300); let webp_proc = ImageProcessor::new().with_output_format(OutputFormat::WebP); - assert_eq!(webp_proc.process(&png, "image/png").unwrap().original.mime_type, "image/webp"); + assert_eq!( + webp_proc + .process(&png, "image/png") + .unwrap() + .original + .mime_type, + "image/webp" + ); let jpeg_proc = ImageProcessor::new().with_output_format(OutputFormat::Jpeg); - assert_eq!(jpeg_proc.process(&png, "image/png").unwrap().original.mime_type, "image/jpeg"); + assert_eq!( + jpeg_proc + .process(&png, "image/png") + .unwrap() + .original + .mime_type, + "image/jpeg" + ); let png_proc = ImageProcessor::new().with_output_format(OutputFormat::Png); - assert_eq!(png_proc.process(&jpeg, "image/jpeg").unwrap().original.mime_type, "image/png"); + assert_eq!( + png_proc + .process(&jpeg, "image/jpeg") + .unwrap() + .original + .mime_type, + "image/png" + ); } #[test] @@ -116,12 +183,22 @@ fn test_size_and_dimension_limits() { let max_dim = ImageProcessor::new().with_max_dimension(1000); let large = create_test_png(2000, 2000); let result = max_dim.process(&large, "image/png"); - assert!(matches!(result, Err(ImageError::TooLarge { width: 2000, height: 2000, max_dimension: 1000 }))); + assert!(matches!( + result, + Err(ImageError::TooLarge { + width: 2000, + height: 2000, + max_dimension: 1000 + }) + )); let max_file = ImageProcessor::new().with_max_file_size(100); let data = create_test_png(500, 500); let result = max_file.process(&data, "image/png"); - assert!(matches!(result, Err(ImageError::FileTooLarge { max_size: 100, .. }))); + assert!(matches!( + result, + Err(ImageError::FileTooLarge { max_size: 100, .. }) + )); } #[test] diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index be52076..8d290f4 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -1,12 +1,6 @@ #![allow(unused_imports)] mod common; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use tranquil_pds::auth::{ - self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, - TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, - create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token, - verify_access_token, verify_refresh_token, verify_token, -}; use chrono::{Duration, Utc}; use common::{base_url, client, create_account_and_login, get_db_connection_string}; use k256::SecretKey; @@ -15,6 +9,12 @@ use rand::rngs::OsRng; use reqwest::StatusCode; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use tranquil_pds::auth::{ + self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, + create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token, + verify_access_token, verify_refresh_token, verify_token, +}; fn generate_user_key() -> Vec { let secret_key = SecretKey::random(&mut OsRng); @@ -48,27 +48,51 @@ fn test_signature_attacks() { let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature); let result = verify_access_token(&forged_token, &key_bytes); assert!(result.is_err(), "Forged signature must be rejected"); - assert!(result.err().unwrap().to_string().to_lowercase().contains("signature")); + assert!( + result + .err() + .unwrap() + .to_string() + .to_lowercase() + .contains("signature") + ); let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); payload["sub"] = json!("did:plc:attacker"); let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); - assert!(verify_access_token(&modified_token, &key_bytes).is_err(), "Modified payload must be rejected"); + assert!( + verify_access_token(&modified_token, &key_bytes).is_err(), + "Modified payload must be rejected" + ); let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); let truncated_sig = URL_SAFE_NO_PAD.encode(&sig_bytes[..32]); let truncated_token = format!("{}.{}.{}", parts[0], parts[1], truncated_sig); - assert!(verify_access_token(&truncated_token, &key_bytes).is_err(), "Truncated signature must be rejected"); + assert!( + verify_access_token(&truncated_token, &key_bytes).is_err(), + "Truncated signature must be rejected" + ); let mut extended_sig = sig_bytes.clone(); extended_sig.extend_from_slice(&[0u8; 32]); - let extended_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&extended_sig)); - assert!(verify_access_token(&extended_token, &key_bytes).is_err(), "Extended signature must be rejected"); + let extended_token = format!( + "{}.{}.{}", + parts[0], + parts[1], + URL_SAFE_NO_PAD.encode(&extended_sig) + ); + assert!( + verify_access_token(&extended_token, &key_bytes).is_err(), + "Extended signature must be rejected" + ); let key_bytes_user2 = generate_user_key(); - assert!(verify_access_token(&token, &key_bytes_user2).is_err(), "Token signed with different key must be rejected"); + assert!( + verify_access_token(&token, &key_bytes_user2).is_err(), + "Token signed with different key must be rejected" + ); } #[test] @@ -83,7 +107,10 @@ fn test_algorithm_substitution_attacks() { "jti": "attack-token", "scope": SCOPE_ACCESS }); let none_token = create_unsigned_jwt(&none_header, &claims); - assert!(verify_access_token(&none_token, &key_bytes).is_err(), "Algorithm 'none' must be rejected"); + assert!( + verify_access_token(&none_token, &key_bytes).is_err(), + "Algorithm 'none' must be rejected" + ); let hs256_header = json!({ "alg": "HS256", "typ": TOKEN_TYPE_ACCESS }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&hs256_header).unwrap()); @@ -95,14 +122,21 @@ fn test_algorithm_substitution_attacks() { mac.update(message.as_bytes()); let hmac_sig = mac.finalize().into_bytes(); let hs256_token = format!("{}.{}", message, URL_SAFE_NO_PAD.encode(&hmac_sig)); - assert!(verify_access_token(&hs256_token, &key_bytes).is_err(), "HS256 substitution must be rejected"); + assert!( + verify_access_token(&hs256_token, &key_bytes).is_err(), + "HS256 substitution must be rejected" + ); for (alg, sig_len) in [("RS256", 256), ("ES256", 64)] { let header = json!({ "alg": alg, "typ": TOKEN_TYPE_ACCESS }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); let fake_sig = URL_SAFE_NO_PAD.encode(&vec![1u8; sig_len]); let token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); - assert!(verify_access_token(&token, &key_bytes).is_err(), "{} substitution must be rejected", alg); + assert!( + verify_access_token(&token, &key_bytes).is_err(), + "{} substitution must be rejected", + alg + ); } } @@ -114,15 +148,31 @@ fn test_token_type_confusion() { let refresh_token = create_refresh_token(did, &key_bytes).expect("create refresh token"); let result = verify_access_token(&refresh_token, &key_bytes); assert!(result.is_err(), "Refresh token as access must be rejected"); - assert!(result.err().unwrap().to_string().contains("Invalid token type")); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("Invalid token type") + ); let access_token = create_access_token(did, &key_bytes).expect("create access token"); let result = verify_refresh_token(&access_token, &key_bytes); assert!(result.is_err(), "Access token as refresh must be rejected"); - assert!(result.err().unwrap().to_string().contains("Invalid token type")); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("Invalid token type") + ); - let service_token = create_service_token(did, "did:web:target", "com.example.method", &key_bytes).unwrap(); - assert!(verify_access_token(&service_token, &key_bytes).is_err(), "Service token as access must be rejected"); + let service_token = + create_service_token(did, "did:web:target", "com.example.method", &key_bytes).unwrap(); + assert!( + verify_access_token(&service_token, &key_bytes).is_err(), + "Service token as access must be rejected" + ); } #[test] @@ -136,22 +186,44 @@ fn test_scope_validation() { "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": "admin.all" }); - let result = verify_access_token(&create_custom_jwt(&header, &invalid_scope, &key_bytes), &key_bytes); - assert!(result.is_err() && result.err().unwrap().to_string().contains("Invalid token scope")); + let result = verify_access_token( + &create_custom_jwt(&header, &invalid_scope, &key_bytes), + &key_bytes, + ); + assert!( + result.is_err() + && result + .err() + .unwrap() + .to_string() + .contains("Invalid token scope") + ); let empty_scope = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": "" }); - assert!(verify_access_token(&create_custom_jwt(&header, &empty_scope, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &empty_scope, &key_bytes), + &key_bytes + ) + .is_err() + ); let missing_scope = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test" }); - assert!(verify_access_token(&create_custom_jwt(&header, &missing_scope, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &missing_scope, &key_bytes), + &key_bytes + ) + .is_err() + ); for scope in [SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED] { let claims = json!({ @@ -159,7 +231,10 @@ fn test_scope_validation() { "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": scope }); - assert!(verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok()); + assert!( + verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes) + .is_ok() + ); } let refresh_scope = json!({ @@ -167,7 +242,13 @@ fn test_scope_validation() { "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": SCOPE_REFRESH }); - assert!(verify_access_token(&create_custom_jwt(&header, &refresh_scope, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &refresh_scope, &key_bytes), + &key_bytes + ) + .is_err() + ); } #[test] @@ -181,52 +262,97 @@ fn test_expiration_and_timing() { "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": now - 7200, "exp": now - 3600, "jti": "test", "scope": SCOPE_ACCESS }); - let result = verify_access_token(&create_custom_jwt(&header, &expired, &key_bytes), &key_bytes); + let result = verify_access_token( + &create_custom_jwt(&header, &expired, &key_bytes), + &key_bytes, + ); assert!(result.is_err() && result.err().unwrap().to_string().contains("expired")); let future_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": now + 60, "exp": now + 7200, "jti": "test", "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &future_iat, &key_bytes), &key_bytes).is_ok()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &future_iat, &key_bytes), + &key_bytes + ) + .is_ok() + ); let just_expired = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": now - 10, "exp": now - 1, "jti": "test", "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &just_expired, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &just_expired, &key_bytes), + &key_bytes + ) + .is_err() + ); let far_future = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": now, "exp": i64::MAX, "jti": "test", "scope": SCOPE_ACCESS }); - let _ = verify_access_token(&create_custom_jwt(&header, &far_future, &key_bytes), &key_bytes); + let _ = verify_access_token( + &create_custom_jwt(&header, &far_future, &key_bytes), + &key_bytes, + ); let negative_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": -1000000000i64, "exp": now + 3600, "jti": "test", "scope": SCOPE_ACCESS }); - let _ = verify_access_token(&create_custom_jwt(&header, &negative_iat, &key_bytes), &key_bytes); + let _ = verify_access_token( + &create_custom_jwt(&header, &negative_iat, &key_bytes), + &key_bytes, + ); } #[test] fn test_malformed_tokens() { let key_bytes = generate_user_key(); - for token in ["", "not-a-token", "one.two", "one.two.three.four", "....", - "eyJhbGciOiJFUzI1NksifQ", "eyJhbGciOiJFUzI1NksifQ.", "eyJhbGciOiJFUzI1NksifQ..", - ".eyJzdWIiOiJ0ZXN0In0.", "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig"] { - assert!(verify_access_token(token, &key_bytes).is_err(), "Malformed token must be rejected"); + for token in [ + "", + "not-a-token", + "one.two", + "one.two.three.four", + "....", + "eyJhbGciOiJFUzI1NksifQ", + "eyJhbGciOiJFUzI1NksifQ.", + "eyJhbGciOiJFUzI1NksifQ..", + ".eyJzdWIiOiJ0ZXN0In0.", + "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig", + ] { + assert!( + verify_access_token(token, &key_bytes).is_err(), + "Malformed token must be rejected" + ); } let invalid_header = URL_SAFE_NO_PAD.encode("{not valid json}"); let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"sub":"test"}"#); let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); - assert!(verify_access_token(&format!("{}.{}.{}", invalid_header, claims_b64, fake_sig), &key_bytes).is_err()); + assert!( + verify_access_token( + &format!("{}.{}.{}", invalid_header, claims_b64, fake_sig), + &key_bytes + ) + .is_err() + ); let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K","typ":"at+jwt"}"#); let invalid_claims = URL_SAFE_NO_PAD.encode("{not valid json}"); - assert!(verify_access_token(&format!("{}.{}.{}", header_b64, invalid_claims, fake_sig), &key_bytes).is_err()); + assert!( + verify_access_token( + &format!("{}.{}.{}", header_b64, invalid_claims, fake_sig), + &key_bytes + ) + .is_err() + ); } #[test] @@ -239,32 +365,59 @@ fn test_claim_validation() { "iss": did, "sub": did, "aud": "did:web:test", "iat": Utc::now().timestamp(), "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &missing_exp, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &missing_exp, &key_bytes), + &key_bytes + ) + .is_err() + ); let missing_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test", "exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &missing_iat, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &missing_iat, &key_bytes), + &key_bytes + ) + .is_err() + ); let missing_sub = json!({ "iss": did, "aud": "did:web:test", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &missing_sub, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &missing_sub, &key_bytes), + &key_bytes + ) + .is_err() + ); let wrong_types = json!({ "iss": 12345, "sub": ["did:plc:test"], "aud": {"url": "did:web:test"}, "iat": "not a number", "exp": "also not a number", "jti": null, "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &wrong_types, &key_bytes), &key_bytes).is_err()); + assert!( + verify_access_token( + &create_custom_jwt(&header, &wrong_types, &key_bytes), + &key_bytes + ) + .is_err() + ); let unicode_injection = json!({ "iss": "did:plc:test\u{0000}attacker", "sub": "did:plc:test\u{202E}rekatta", "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": SCOPE_ACCESS }); - if let Ok(data) = verify_access_token(&create_custom_jwt(&header, &unicode_injection, &key_bytes), &key_bytes) { + if let Ok(data) = verify_access_token( + &create_custom_jwt(&header, &unicode_injection, &key_bytes), + &key_bytes, + ) { assert!(!data.claims.sub.contains('\0')); } } @@ -308,14 +461,26 @@ fn test_header_injection_and_constant_time() { "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "test", "scope": SCOPE_ACCESS }); - assert!(verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok()); + assert!( + verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok() + ); let valid_token = create_access_token(did, &key_bytes).expect("create token"); let parts: Vec<&str> = valid_token.split('.').collect(); let mut almost_valid = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); almost_valid[0] ^= 1; - let almost_valid_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&almost_valid)); - let completely_invalid_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&[0xFFu8; 64])); + let almost_valid_token = format!( + "{}.{}.{}", + parts[0], + parts[1], + URL_SAFE_NO_PAD.encode(&almost_valid) + ); + let completely_invalid_token = format!( + "{}.{}.{}", + parts[0], + parts[1], + URL_SAFE_NO_PAD.encode(&[0xFFu8; 64]) + ); let _ = verify_access_token(&almost_valid_token, &key_bytes); let _ = verify_access_token(&completely_invalid_token, &key_bytes); } @@ -327,10 +492,17 @@ async fn test_server_rejects_invalid_tokens() { let key_bytes = generate_user_key(); let forged_token = create_access_token("did:plc:fake-user", &key_bytes).unwrap(); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", forged_token)) - .send().await.unwrap(); - assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged token must be rejected"); + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Forged token must be rejected" + ); let (access_jwt, _did) = create_account_and_login(&http_client).await; let parts: Vec<&str> = access_jwt.split('.').collect(); @@ -338,19 +510,35 @@ async fn test_server_rejects_invalid_tokens() { let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); payload["exp"] = json!(Utc::now().timestamp() - 3600); - let expired_token = format!("{}.{}.{}", parts[0], URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), parts[2]); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let expired_token = format!( + "{}.{}.{}", + parts[0], + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), + parts[2] + ); + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", expired_token)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); let mut tampered_payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); tampered_payload["sub"] = json!("did:plc:attacker"); tampered_payload["iss"] = json!("did:plc:attacker"); - let tampered_token = format!("{}.{}.{}", parts[0], URL_SAFE_NO_PAD.encode(serde_json::to_string(&tampered_payload).unwrap()), parts[2]); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let tampered_token = format!( + "{}.{}.{}", + parts[0], + URL_SAFE_NO_PAD.encode(serde_json::to_string(&tampered_payload).unwrap()), + parts[2] + ); + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", tampered_token)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); } @@ -360,29 +548,44 @@ async fn test_authorization_header_formats() { let http_client = client(); let (access_jwt, _did) = create_account_and_login(&http_client).await; - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Basic {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", &access_jwt) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", "Bearer ") - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); } @@ -392,19 +595,28 @@ async fn test_session_lifecycle_security() { let http_client = client(); let (access_jwt, _did) = create_account_and_login(&http_client).await; - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); - let logout = http_client.post(format!("{}/xrpc/com.atproto.server.deleteSession", url)) + let logout = http_client + .post(format!("{}/xrpc/com.atproto.server.deleteSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(logout.status(), StatusCode::OK); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); } @@ -414,20 +626,27 @@ async fn test_deactivated_account_behavior() { let http_client = client(); let (access_jwt, _did) = create_account_and_login(&http_client).await; - let deact = http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) + let deact = http_client + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) .header("Authorization", format!("Bearer {}", access_jwt)) .json(&json!({})) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(deact.status(), StatusCode::OK); - let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .header("Authorization", format!("Bearer {}", access_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); assert_eq!(body["active"], false); - let post_res = http_client.post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + let post_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .header("Authorization", format!("Bearer {}", access_jwt)) .json(&json!({ "repo": _did, @@ -438,7 +657,9 @@ async fn test_deactivated_account_behavior() { "createdAt": "2024-01-01T00:00:00Z" } })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(post_res.status(), StatusCode::UNAUTHORIZED); let post_body: Value = post_res.json().await.unwrap(); assert_eq!(post_body["error"], "AccountDeactivated"); @@ -452,9 +673,12 @@ async fn test_refresh_token_replay_protection() { let handle = format!("rt-replay-jwt-{}", ts); let email = format!("rt-replay-jwt-{}@example.com", ts); - let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": "test-password-123" })) - .send().await.unwrap(); + .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(); @@ -462,26 +686,36 @@ async fn test_refresh_token_replay_protection() { let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(2) .connect(&get_db_connection_string().await) - .await.unwrap(); + .await + .unwrap(); let code: String = sqlx::query_scalar!( "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", did ).fetch_one(&pool).await.unwrap(); - let confirm = http_client.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) + let confirm = http_client + .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) .json(&json!({ "did": did, "verificationCode": code })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(confirm.status(), StatusCode::OK); let confirmed: Value = confirm.json().await.unwrap(); let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string(); - let first = http_client.post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + let first = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) .header("Authorization", format!("Bearer {}", refresh_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(first.status(), StatusCode::OK); - let replay = http_client.post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + let replay = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) .header("Authorization", format!("Bearer {}", refresh_jwt)) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); } diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index 46cdde5..3b8f2aa 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -26,24 +26,45 @@ async fn test_record_crud_lifecycle() { } }); let create_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&create_payload) .send() .await .expect("Failed to send create request"); - assert_eq!(create_res.status(), StatusCode::OK, "Failed to create record"); - let create_body: Value = create_res.json().await.expect("create response was not JSON"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Failed to create record" + ); + let create_body: Value = create_res + .json() + .await + .expect("create response was not JSON"); let uri = create_body["uri"].as_str().unwrap(); let initial_cid = create_body["cid"].as_str().unwrap().to_string(); - let params = [("repo", did.as_str()), ("collection", collection), ("rkey", &rkey)]; + let params = [ + ("repo", did.as_str()), + ("collection", collection), + ("rkey", &rkey), + ]; let get_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) .query(¶ms) .send() .await .expect("Failed to send get request"); - assert_eq!(get_res.status(), StatusCode::OK, "Failed to get record after create"); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Failed to get record after create" + ); let get_body: Value = get_res.json().await.expect("get response was not JSON"); assert_eq!(get_body["uri"], uri); assert_eq!(get_body["value"]["text"], original_text); @@ -56,23 +77,42 @@ async fn test_record_crud_lifecycle() { "swapRecord": initial_cid }); let update_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&update_payload) .send() .await .expect("Failed to send update request"); - assert_eq!(update_res.status(), StatusCode::OK, "Failed to update record"); - let update_body: Value = update_res.json().await.expect("update response was not JSON"); + assert_eq!( + update_res.status(), + StatusCode::OK, + "Failed to update record" + ); + let update_body: Value = update_res + .json() + .await + .expect("update response was not JSON"); let updated_cid = update_body["cid"].as_str().unwrap().to_string(); let get_updated_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) .query(¶ms) .send() .await .expect("Failed to send get-after-update request"); - let get_updated_body: Value = get_updated_res.json().await.expect("get-updated response was not JSON"); - assert_eq!(get_updated_body["value"]["text"], updated_text, "Text was not updated"); + let get_updated_body: Value = get_updated_res + .json() + .await + .expect("get-updated response was not JSON"); + assert_eq!( + get_updated_body["value"]["text"], updated_text, + "Text was not updated" + ); let stale_update_payload = json!({ "repo": did, "collection": collection, @@ -81,13 +121,20 @@ async fn test_record_crud_lifecycle() { "swapRecord": initial_cid }); let stale_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&stale_update_payload) .send() .await .expect("Failed to send stale update"); - assert_eq!(stale_res.status(), StatusCode::CONFLICT, "Stale update should cause 409"); + assert_eq!( + stale_res.status(), + StatusCode::CONFLICT, + "Stale update should cause 409" + ); let good_update_payload = json!({ "repo": did, "collection": collection, @@ -96,29 +143,50 @@ async fn test_record_crud_lifecycle() { "swapRecord": updated_cid }); let good_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&good_update_payload) .send() .await .expect("Failed to send good update"); - assert_eq!(good_res.status(), StatusCode::OK, "Good update should succeed"); + assert_eq!( + good_res.status(), + StatusCode::OK, + "Good update should succeed" + ); let delete_payload = json!({ "repo": did, "collection": collection, "rkey": rkey }); let delete_res = client - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&delete_payload) .send() .await .expect("Failed to send delete request"); - assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete record"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "Failed to delete record" + ); let get_deleted_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) .query(¶ms) .send() .await .expect("Failed to send get-after-delete request"); - assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Record should be deleted"); + assert_eq!( + get_deleted_res.status(), + StatusCode::NOT_FOUND, + "Record should be deleted" + ); } #[tokio::test] @@ -127,7 +195,10 @@ async fn test_profile_with_blob_lifecycle() { let (did, jwt) = setup_new_user("profile-blob").await; let blob_data = b"This is test blob data for a profile avatar"; let upload_res = client - .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) .header(header::CONTENT_TYPE, "text/plain") .bearer_auth(&jwt) .body(blob_data.to_vec()) @@ -149,18 +220,32 @@ async fn test_profile_with_blob_lifecycle() { } }); let create_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&profile_payload) .send() .await .expect("Failed to create profile"); - assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Failed to create profile" + ); let create_body: Value = create_res.json().await.unwrap(); let initial_cid = create_body["cid"].as_str().unwrap().to_string(); let get_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")]) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) .send() .await .expect("Failed to get profile"); @@ -176,16 +261,30 @@ async fn test_profile_with_blob_lifecycle() { "swapRecord": initial_cid }); let update_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&jwt) .json(&update_payload) .send() .await .expect("Failed to update profile"); - assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile"); + assert_eq!( + update_res.status(), + StatusCode::OK, + "Failed to update profile" + ); let get_updated_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")]) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) .send() .await .expect("Failed to get updated profile"); @@ -198,7 +297,8 @@ async fn test_reply_thread_lifecycle() { let client = client(); let (alice_did, alice_jwt) = setup_new_user("alice-thread").await; let (bob_did, bob_jwt) = setup_new_user("bob-thread").await; - let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await; + let (root_uri, root_cid) = + create_post(&client, &alice_did, &alice_jwt, "This is the root post").await; tokio::time::sleep(Duration::from_millis(100)).await; let reply_collection = "app.bsky.feed.post"; let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis()); @@ -217,7 +317,10 @@ async fn test_reply_thread_lifecycle() { } }); let reply_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&bob_jwt) .json(&reply_payload) .send() @@ -228,8 +331,15 @@ async fn test_reply_thread_lifecycle() { let reply_uri = reply_body["uri"].as_str().unwrap(); let reply_cid = reply_body["cid"].as_str().unwrap(); let get_reply_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", bob_did.as_str()), ("collection", reply_collection), ("rkey", reply_rkey.as_str())]) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", reply_collection), + ("rkey", reply_rkey.as_str()), + ]) .send() .await .expect("Failed to get reply"); @@ -253,13 +363,20 @@ async fn test_reply_thread_lifecycle() { } }); let nested_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&alice_jwt) .json(&nested_payload) .send() .await .expect("Failed to create nested reply"); - assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply"); + assert_eq!( + nested_res.status(), + StatusCode::OK, + "Failed to create nested reply" + ); } #[tokio::test] @@ -276,31 +393,57 @@ async fn test_authorization_protects_repos() { "record": { "$type": "app.bsky.feed.post", "text": "Bob trying to post as Alice", "createdAt": Utc::now().to_rfc3339() } }); let write_res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(&bob_jwt) .json(&post_payload) .send() .await .expect("Failed to send request"); - assert!(write_res.status() == StatusCode::FORBIDDEN || write_res.status() == StatusCode::UNAUTHORIZED, - "Expected 403/401 for writing to another user's repo, got {}", write_res.status()); - let delete_payload = json!({ "repo": alice_did, "collection": "app.bsky.feed.post", "rkey": post_rkey }); + assert!( + write_res.status() == StatusCode::FORBIDDEN + || write_res.status() == StatusCode::UNAUTHORIZED, + "Expected 403/401 for writing to another user's repo, got {}", + write_res.status() + ); + let delete_payload = + json!({ "repo": alice_did, "collection": "app.bsky.feed.post", "rkey": post_rkey }); let delete_res = client - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) .bearer_auth(&bob_jwt) .json(&delete_payload) .send() .await .expect("Failed to send request"); - assert!(delete_res.status() == StatusCode::FORBIDDEN || delete_res.status() == StatusCode::UNAUTHORIZED, - "Expected 403/401 for deleting another user's record, got {}", delete_res.status()); + assert!( + delete_res.status() == StatusCode::FORBIDDEN + || delete_res.status() == StatusCode::UNAUTHORIZED, + "Expected 403/401 for deleting another user's record, got {}", + delete_res.status() + ); let get_res = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", alice_did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", post_rkey)]) + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", alice_did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", post_rkey), + ]) .send() .await .expect("Failed to verify record exists"); - assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist"); + assert_eq!( + get_res.status(), + StatusCode::OK, + "Record should still exist" + ); } #[tokio::test] @@ -317,7 +460,10 @@ async fn test_apply_writes_batch() { ] }); let apply_res = client - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) .bearer_auth(&jwt) .json(&writes_payload) .send() @@ -325,21 +471,48 @@ async fn test_apply_writes_batch() { .expect("Failed to apply writes"); assert_eq!(apply_res.status(), StatusCode::OK); let get_post1 = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")]) - .send().await.expect("Failed to get post 1"); + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-1"), + ]) + .send() + .await + .expect("Failed to get post 1"); assert_eq!(get_post1.status(), StatusCode::OK); let post1_body: Value = get_post1.json().await.unwrap(); assert_eq!(post1_body["value"]["text"], "First batch post"); let get_post2 = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-2")]) - .send().await.expect("Failed to get post 2"); + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-2"), + ]) + .send() + .await + .expect("Failed to get post 2"); assert_eq!(get_post2.status(), StatusCode::OK); let get_profile = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")]) - .send().await.expect("Failed to get profile"); + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get profile"); let profile_body: Value = get_profile.json().await.unwrap(); assert_eq!(profile_body["value"]["displayName"], "Batch User"); let update_writes = json!({ @@ -350,7 +523,10 @@ async fn test_apply_writes_batch() { ] }); let update_res = client - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) .bearer_auth(&jwt) .json(&update_writes) .send() @@ -358,25 +534,59 @@ async fn test_apply_writes_batch() { .expect("Failed to apply update writes"); assert_eq!(update_res.status(), StatusCode::OK); let get_updated_profile = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")]) - .send().await.expect("Failed to get updated profile"); + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get updated profile"); let updated_profile: Value = get_updated_profile.json().await.unwrap(); - assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User"); + assert_eq!( + updated_profile["value"]["displayName"], + "Updated Batch User" + ); let get_deleted_post = client - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")]) - .send().await.expect("Failed to check deleted post"); - assert_eq!(get_deleted_post.status(), StatusCode::NOT_FOUND, "Batch-deleted post should be gone"); + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-1"), + ]) + .send() + .await + .expect("Failed to check deleted post"); + assert_eq!( + get_deleted_post.status(), + StatusCode::NOT_FOUND, + "Batch-deleted post should be gone" + ); } -async fn create_post_with_rkey(client: &reqwest::Client, did: &str, jwt: &str, rkey: &str, text: &str) -> (String, String) { +async fn create_post_with_rkey( + client: &reqwest::Client, + did: &str, + jwt: &str, + rkey: &str, + text: &str, +) -> (String, String) { let payload = json!({ "repo": did, "collection": "app.bsky.feed.post", "rkey": rkey, "record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": Utc::now().to_rfc3339() } }); let res = client - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) .bearer_auth(jwt) .json(&payload) .send() @@ -384,7 +594,10 @@ async fn create_post_with_rkey(client: &reqwest::Client, did: &str, jwt: &str, r .expect("Failed to create record"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); - (body["uri"].as_str().unwrap().to_string(), body["cid"].as_str().unwrap().to_string()) + ( + body["uri"].as_str().unwrap().to_string(), + body["cid"].as_str().unwrap().to_string(), + ) } #[tokio::test] @@ -392,19 +605,38 @@ async fn test_list_records_comprehensive() { let client = client(); let (did, jwt) = setup_new_user("list-records-test").await; for i in 0..5 { - create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await; + create_post_with_rkey( + &client, + &did, + &jwt, + &format!("post{:02}", i), + &format!("Post {}", i), + ) + .await; tokio::time::sleep(Duration::from_millis(50)).await; } let res = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) - .send().await.expect("Failed to list records"); + .send() + .await + .expect("Failed to list records"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let records = body["records"].as_array().unwrap(); assert_eq!(records.len(), 5); - let rkeys: Vec<&str> = records.iter().map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect(); - assert_eq!(rkeys, vec!["post04", "post03", "post02", "post01", "post00"], "Default order should be DESC"); + let rkeys: Vec<&str> = records + .iter() + .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .collect(); + assert_eq!( + rkeys, + vec!["post04", "post03", "post02", "post01", "post00"], + "Default order should be DESC" + ); for record in records { assert!(record["uri"].is_string()); assert!(record["cid"].is_string()); @@ -412,52 +644,132 @@ async fn test_list_records_comprehensive() { assert!(record["value"].is_object()); } let rev_res = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("reverse", "true")]) - .send().await.expect("Failed to list records reverse"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("reverse", "true"), + ]) + .send() + .await + .expect("Failed to list records reverse"); let rev_body: Value = rev_res.json().await.unwrap(); - let rev_rkeys: Vec<&str> = rev_body["records"].as_array().unwrap().iter() - .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect(); - assert_eq!(rev_rkeys, vec!["post00", "post01", "post02", "post03", "post04"], "reverse=true should give ASC"); + let rev_rkeys: Vec<&str> = rev_body["records"] + .as_array() + .unwrap() + .iter() + .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .collect(); + assert_eq!( + rev_rkeys, + vec!["post00", "post01", "post02", "post03", "post04"], + "reverse=true should give ASC" + ); let page1 = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2")]) - .send().await.expect("Failed to list page 1"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "2"), + ]) + .send() + .await + .expect("Failed to list page 1"); let page1_body: Value = page1.json().await.unwrap(); let page1_records = page1_body["records"].as_array().unwrap(); assert_eq!(page1_records.len(), 2); let cursor = page1_body["cursor"].as_str().expect("Should have cursor"); let page2 = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2"), ("cursor", cursor)]) - .send().await.expect("Failed to list page 2"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "2"), + ("cursor", cursor), + ]) + .send() + .await + .expect("Failed to list page 2"); let page2_body: Value = page2.json().await.unwrap(); let page2_records = page2_body["records"].as_array().unwrap(); assert_eq!(page2_records.len(), 2); - let all_uris: Vec<&str> = page1_records.iter().chain(page2_records.iter()) - .map(|r| r["uri"].as_str().unwrap()).collect(); + let all_uris: Vec<&str> = page1_records + .iter() + .chain(page2_records.iter()) + .map(|r| r["uri"].as_str().unwrap()) + .collect(); let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect(); - assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records"); + assert_eq!( + all_uris.len(), + unique_uris.len(), + "Cursor pagination should not repeat records" + ); let range_res = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), - ("rkeyStart", "post01"), ("rkeyEnd", "post03"), ("reverse", "true")]) - .send().await.expect("Failed to list range"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkeyStart", "post01"), + ("rkeyEnd", "post03"), + ("reverse", "true"), + ]) + .send() + .await + .expect("Failed to list range"); let range_body: Value = range_res.json().await.unwrap(); - let range_rkeys: Vec<&str> = range_body["records"].as_array().unwrap().iter() - .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect(); + let range_rkeys: Vec<&str> = range_body["records"] + .as_array() + .unwrap() + .iter() + .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()) + .collect(); for rkey in &range_rkeys { - assert!(*rkey >= "post01" && *rkey <= "post03", "Range should be inclusive"); + assert!( + *rkey >= "post01" && *rkey <= "post03", + "Range should be inclusive" + ); } let limit_res = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "1000")]) - .send().await.expect("Failed with high limit"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "1000"), + ]) + .send() + .await + .expect("Failed with high limit"); let limit_body: Value = limit_res.json().await.unwrap(); - assert!(limit_body["records"].as_array().unwrap().len() <= 100, "Limit should be clamped to max 100"); + assert!( + limit_body["records"].as_array().unwrap().len() <= 100, + "Limit should be clamped to max 100" + ); let not_found_res = client - .get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await)) - .query(&[("repo", "did:plc:nonexistent12345"), ("collection", "app.bsky.feed.post")]) - .send().await.expect("Failed with nonexistent repo"); + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", "did:plc:nonexistent12345"), + ("collection", "app.bsky.feed.post"), + ]) + .send() + .await + .expect("Failed with nonexistent repo"); assert_eq!(not_found_res.status(), StatusCode::NOT_FOUND); } diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs index 43bc05a..25a1799 100644 --- a/tests/lifecycle_social.rs +++ b/tests/lifecycle_social.rs @@ -4,7 +4,7 @@ use chrono::Utc; use common::*; use helpers::*; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[tokio::test] async fn test_like_lifecycle() { diff --git a/tests/notifications.rs b/tests/notifications.rs index bcb445b..187118b 100644 --- a/tests/notifications.rs +++ b/tests/notifications.rs @@ -1,8 +1,8 @@ mod common; +use sqlx::PgPool; use tranquil_pds::comms::{ CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome, }; -use sqlx::PgPool; async fn get_pool() -> PgPool { let conn_str = common::get_db_connection_string().await; @@ -109,9 +109,7 @@ async fn test_comms_queue_status_index() { "Test".to_string(), "Body".to_string(), ); - enqueue_comms(&pool, item) - .await - .expect("Failed to enqueue"); + enqueue_comms(&pool, item).await.expect("Failed to enqueue"); } let final_count: i64 = sqlx::query_scalar!( "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", diff --git a/tests/oauth.rs b/tests/oauth.rs index b544530..7562ff3 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -11,7 +11,10 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn no_redirect_client() -> reqwest::Client { - reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap() + reqwest::Client::builder() + .redirect(redirect::Policy::none()) + .build() + .unwrap() } fn generate_pkce() -> (String, String) { @@ -47,25 +50,65 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { async fn test_oauth_metadata_endpoints() { let url = base_url().await; let client = client(); - let pr_res = client.get(format!("{}/.well-known/oauth-protected-resource", url)).send().await.unwrap(); + let pr_res = client + .get(format!("{}/.well-known/oauth-protected-resource", url)) + .send() + .await + .unwrap(); assert_eq!(pr_res.status(), StatusCode::OK); let pr_body: Value = pr_res.json().await.unwrap(); assert!(pr_body["resource"].is_string()); assert!(pr_body["authorization_servers"].is_array()); - assert!(pr_body["bearer_methods_supported"].as_array().unwrap().contains(&json!("header"))); - let as_res = client.get(format!("{}/.well-known/oauth-authorization-server", url)).send().await.unwrap(); + assert!( + pr_body["bearer_methods_supported"] + .as_array() + .unwrap() + .contains(&json!("header")) + ); + let as_res = client + .get(format!("{}/.well-known/oauth-authorization-server", url)) + .send() + .await + .unwrap(); assert_eq!(as_res.status(), StatusCode::OK); let as_body: Value = as_res.json().await.unwrap(); assert!(as_body["issuer"].is_string()); assert!(as_body["authorization_endpoint"].is_string()); assert!(as_body["token_endpoint"].is_string()); assert!(as_body["jwks_uri"].is_string()); - assert!(as_body["response_types_supported"].as_array().unwrap().contains(&json!("code"))); - assert!(as_body["grant_types_supported"].as_array().unwrap().contains(&json!("authorization_code"))); - assert!(as_body["code_challenge_methods_supported"].as_array().unwrap().contains(&json!("S256"))); - assert_eq!(as_body["require_pushed_authorization_requests"], json!(true)); - assert!(as_body["dpop_signing_alg_values_supported"].as_array().unwrap().contains(&json!("ES256"))); - let jwks_res = client.get(format!("{}/oauth/jwks", url)).send().await.unwrap(); + assert!( + as_body["response_types_supported"] + .as_array() + .unwrap() + .contains(&json!("code")) + ); + assert!( + as_body["grant_types_supported"] + .as_array() + .unwrap() + .contains(&json!("authorization_code")) + ); + assert!( + as_body["code_challenge_methods_supported"] + .as_array() + .unwrap() + .contains(&json!("S256")) + ); + assert_eq!( + as_body["require_pushed_authorization_requests"], + json!(true) + ); + assert!( + as_body["dpop_signing_alg_values_supported"] + .as_array() + .unwrap() + .contains(&json!("ES256")) + ); + let jwks_res = client + .get(format!("{}/oauth/jwks", url)) + .send() + .await + .unwrap(); assert_eq!(jwks_res.status(), StatusCode::OK); let jwks_body: Value = jwks_res.json().await.unwrap(); assert!(jwks_body["keys"].is_array()); @@ -81,9 +124,18 @@ async fn test_par_and_authorize() { let (_, code_challenge) = generate_pkce(); let par_res = 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"), ("scope", "atproto"), ("state", "test-state")]) - .send().await.unwrap(); + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", "test-state"), + ]) + .send() + .await + .unwrap(); assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed"); let par_body: Value = par_res.json().await.unwrap(); assert!(par_body["request_uri"].is_string()); @@ -94,7 +146,9 @@ async fn test_par_and_authorize() { .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") .query(&[("request_uri", request_uri)]) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(auth_res.status(), StatusCode::OK); let auth_body: Value = auth_res.json().await.unwrap(); assert_eq!(auth_body["client_id"], client_id); @@ -103,11 +157,34 @@ async fn test_par_and_authorize() { let invalid_res = client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") - .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")]) - .send().await.unwrap(); + .query(&[( + "request_uri", + "urn:ietf:params:oauth:request_uri:nonexistent", + )]) + .send() + .await + .unwrap(); assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST); - let missing_res = client.get(format!("{}/oauth/authorize", url)).send().await.unwrap(); - assert_eq!(missing_res.status(), StatusCode::BAD_REQUEST); + let missing_client = no_redirect_client(); + let missing_res = missing_client + .get(format!("{}/oauth/authorize", url)) + .send() + .await + .unwrap(); + assert!( + missing_res.status().is_redirection(), + "Should redirect to error page" + ); + let error_location = missing_res + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap(); + assert!( + error_location.contains("oauth/error"), + "Should redirect to error page" + ); } #[tokio::test] @@ -121,7 +198,9 @@ async fn test_full_oauth_flow() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); @@ -133,27 +212,84 @@ async fn test_full_oauth_flow() { let state = format!("state-{}", ts); let par_res = 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"), ("scope", "atproto"), ("state", &state)]) - .send().await.unwrap(); + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", &state), + ]) + .send() + .await + .unwrap(); let par_body: Value = par_res.json().await.unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); - let auth_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")]) + .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(); - assert!(auth_res.status().is_redirection(), "Expected redirect, got {}", auth_res.status()); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - assert!(location.starts_with(redirect_uri), "Redirect to wrong URI"); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Expected OK with JSON response" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri in response") + .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_status = consent_res.status(); + let consent_body: Value = consent_res.json().await.unwrap(); + assert_eq!( + consent_status, + StatusCode::OK, + "Consent should succeed. Got: {:?}", + consent_body + ); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } + assert!( + location.starts_with(redirect_uri), + "Redirect to wrong URI: {}", + location + ); assert!(location.contains("code="), "No code in redirect"); - assert!(location.contains(&format!("state={}", state)), "Wrong state"); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + assert!( + location.contains(&format!("state={}", state)), + "Wrong state" + ); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = 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(); + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); let token_body: Value = token_res.json().await.unwrap(); assert!(token_body["access_token"].is_string()); @@ -165,30 +301,48 @@ async fn test_full_oauth_flow() { let refresh_token = token_body["refresh_token"].as_str().unwrap(); let refresh_res = http_client .post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &client_id)]) - .send().await.unwrap(); + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); assert_eq!(refresh_res.status(), StatusCode::OK); let refresh_body: Value = refresh_res.json().await.unwrap(); assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token); - assert_ne!(refresh_body["refresh_token"].as_str().unwrap(), refresh_token); + assert_ne!( + refresh_body["refresh_token"].as_str().unwrap(), + refresh_token + ); let introspect_res = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(introspect_res.status(), StatusCode::OK); let introspect_body: Value = introspect_res.json().await.unwrap(); assert_eq!(introspect_body["active"], true); let revoke_res = http_client .post(format!("{}/oauth/revoke", url)) .form(&[("token", refresh_body["refresh_token"].as_str().unwrap())]) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(revoke_res.status(), StatusCode::OK); let introspect_after = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) - .send().await.unwrap(); + .send() + .await + .unwrap(); let after_body: Value = introspect_after.json().await.unwrap(); - assert_eq!(after_body["active"], false, "Revoked token should be inactive"); + assert_eq!( + after_body["active"], false, + "Revoked token should be inactive" + ); } #[tokio::test] @@ -198,45 +352,72 @@ async fn test_oauth_error_cases() { let ts = Utc::now().timestamp_millis(); let handle = format!("wrong-creds-{}", ts); let email = format!("wrong-creds-{}@example.com", ts); - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": "correct-password" })) - .send().await.unwrap(); + .send() + .await + .unwrap(); let redirect_uri = "https://example.com/callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, 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(); + .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") - .form(&[("request_uri", request_uri), ("username", &handle), ("password", "wrong-password"), ("remember_device", "false")]) + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_res.status(), StatusCode::FORBIDDEN); let error_body: Value = auth_res.json().await.unwrap(); assert_eq!(error_body["error"], "access_denied"); let unsupported = http_client .post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "client_credentials"), ("client_id", "https://example.com")]) - .send().await.unwrap(); + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", "https://example.com"), + ]) + .send() + .await + .unwrap(); assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST); let body: Value = unsupported.json().await.unwrap(); assert_eq!(body["error"], "unsupported_grant_type"); let invalid_refresh = http_client .post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "refresh_token"), ("refresh_token", "invalid-token"), ("client_id", "https://example.com")]) - .send().await.unwrap(); + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", "invalid-token"), + ("client_id", "https://example.com"), + ]) + .send() + .await + .unwrap(); assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST); let body: Value = invalid_refresh.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); let invalid_introspect = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", "invalid.token.here")]) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(invalid_introspect.status(), StatusCode::OK); let body: Value = invalid_introspect.json().await.unwrap(); assert_eq!(body["active"], false); @@ -244,7 +425,9 @@ async fn test_oauth_error_cases() { .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")]) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST); } @@ -259,55 +442,117 @@ async fn test_oauth_2fa_flow() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); verify_new_account(&http_client, user_did).await; let db_url = get_db_connection_string().await; - let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&db_url) + .await + .unwrap(); sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") - .bind(user_did).execute(&pool).await.unwrap(); + .bind(user_did) + .execute(&pool) + .await + .unwrap(); let redirect_uri = "https://example.com/2fa-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).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(); + .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_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")]) + .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(); - assert!(auth_res.status().is_redirection(), "Should redirect to 2FA page"); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - assert!(location.contains("/oauth/authorize/2fa"), "Should redirect to 2FA page, got: {}", location); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Should return OK with needs_2fa" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["needs_2fa"].as_bool().unwrap_or(false), + "Should need 2FA, got: {:?}", + auth_body + ); let twofa_invalid = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[("request_uri", request_uri), ("code", "000000")]) - .send().await.unwrap(); - assert_eq!(twofa_invalid.status(), StatusCode::OK); - let body = twofa_invalid.text().await.unwrap(); - assert!(body.contains("Invalid verification code") || body.contains("invalid")); - let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") - .bind(request_uri).fetch_one(&pool).await.unwrap(); - let twofa_res = auth_client + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri, "code": "000000"})) + .send() + .await + .unwrap(); + assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN); + let body: Value = twofa_invalid.json().await.unwrap(); + assert!( + body["error_description"] + .as_str() + .unwrap_or("") + .contains("Invalid") + || body["error"].as_str().unwrap_or("") == "invalid_code" + ); + let twofa_code: String = + sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") + .bind(request_uri) + .fetch_one(&pool) + .await + .unwrap(); + let twofa_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[("request_uri", request_uri), ("code", &twofa_code)]) - .send().await.unwrap(); - assert!(twofa_res.status().is_redirection(), "Valid 2FA code should redirect"); - let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap(); + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri, "code": &twofa_code})) + .send() + .await + .unwrap(); + assert_eq!( + twofa_res.status(), + StatusCode::OK, + "Valid 2FA code should succeed" + ); + let twofa_body: Value = twofa_res.json().await.unwrap(); + let final_location = twofa_body["redirect_uri"].as_str().unwrap(); assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); - let auth_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let auth_code = final_location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "authorization_code"), ("code", auth_code), ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier), ("client_id", &client_id)]) - .send().await.unwrap(); + .form(&[ + ("grant_type", "authorization_code"), + ("code", auth_code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); assert_eq!(token_res.status(), StatusCode::OK); let token_body: Value = token_res.json().await.unwrap(); assert_eq!(token_body["sub"], user_did); @@ -324,45 +569,90 @@ async fn test_oauth_2fa_lockout() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) - .send().await.unwrap(); + .send() + .await + .unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); verify_new_account(&http_client, user_did).await; let db_url = get_db_connection_string().await; - let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&db_url) + .await + .unwrap(); sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") - .bind(user_did).execute(&pool).await.unwrap(); + .bind(user_did) + .execute(&pool) + .await + .unwrap(); let redirect_uri = "https://example.com/2fa-lockout-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, 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(); + .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_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")]) + .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(); - assert!(auth_res.status().is_redirection()); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Should return OK with needs_2fa" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["needs_2fa"].as_bool().unwrap_or(false), + "Should need 2FA" + ); for i in 0..5 { let res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[("request_uri", request_uri), ("code", "999999")]) - .send().await.unwrap(); + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri, "code": "999999"})) + .send() + .await + .unwrap(); if i < 4 { - assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.status(), + StatusCode::FORBIDDEN, + "Attempt {} should return 403", + i + ); } } let lockout_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) - .form(&[("request_uri", request_uri), ("code", "999999")]) - .send().await.unwrap(); - let body = lockout_res.text().await.unwrap(); - assert!(body.contains("Too many failed attempts") || body.contains("No 2FA challenge found")); + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri, "code": "999999"})) + .send() + .await + .unwrap(); + let body: Value = lockout_res.json().await.unwrap(); + let desc = body["error_description"].as_str().unwrap_or(""); + assert!( + desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request", + "Expected lockout error, got: {:?}", + body + ); } #[tokio::test] @@ -376,7 +666,9 @@ async fn test_account_selector_with_2fa() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) - .send().await.unwrap(); + .send() + .await + .unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap().to_string(); verify_new_account(&http_client, &user_did).await; @@ -386,63 +678,169 @@ async fn test_account_selector_with_2fa() { 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(); + .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_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "true")]) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true})) .send().await.unwrap(); - assert!(auth_res.status().is_redirection()); - let device_cookie = auth_res.headers().get("set-cookie") + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Expected OK with JSON response" + ); + let device_cookie = auth_res + .headers() + .get("set-cookie") .and_then(|v| v.to_str().ok()) .map(|s| s.split(';').next().unwrap_or("").to_string()) .expect("Should have device cookie"); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .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": true})) + .send().await.unwrap(); + assert_eq!( + consent_res.status(), + StatusCode::OK, + "Consent should succeed" + ); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } assert!(location.contains("code=")); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let _ = 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(); + .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 db_url = get_db_connection_string().await; - let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&db_url) + .await + .unwrap(); sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") - .bind(&user_did).execute(&pool).await.unwrap(); + .bind(&user_did) + .execute(&pool) + .await + .unwrap(); let (code_verifier2, code_challenge2) = generate_pkce(); let par_body2: Value = http_client .post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), - ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")]) - .send().await.unwrap().json().await.unwrap(); + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge2), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let request_uri2 = par_body2["request_uri"].as_str().unwrap(); - let select_res = auth_client + let select_res = http_client .post(format!("{}/oauth/authorize/select", url)) .header("cookie", &device_cookie) - .form(&[("request_uri", request_uri2), ("did", &user_did)]) - .send().await.unwrap(); - assert!(select_res.status().is_redirection()); - let select_location = select_res.headers().get("location").unwrap().to_str().unwrap(); - assert!(select_location.contains("/oauth/authorize/2fa"), "Should redirect to 2FA page"); - let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") - .bind(request_uri2).fetch_one(&pool).await.unwrap(); - let twofa_res = auth_client + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri2, "did": &user_did})) + .send() + .await + .unwrap(); + assert_eq!( + select_res.status(), + StatusCode::OK, + "Select should return OK with JSON" + ); + let select_body: Value = select_res.json().await.unwrap(); + assert!( + select_body["needs_2fa"].as_bool().unwrap_or(false), + "Should need 2FA" + ); + let twofa_code: String = + sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") + .bind(request_uri2) + .fetch_one(&pool) + .await + .unwrap(); + let twofa_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("cookie", &device_cookie) - .form(&[("request_uri", request_uri2), ("code", &twofa_code)]) - .send().await.unwrap(); - assert!(twofa_res.status().is_redirection()); - let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap(); + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri2, "code": &twofa_code})) + .send() + .await + .unwrap(); + assert_eq!( + twofa_res.status(), + StatusCode::OK, + "Valid 2FA should succeed" + ); + let twofa_body: Value = twofa_res.json().await.unwrap(); + let final_location = twofa_body["redirect_uri"].as_str().unwrap(); assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); - let final_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let final_code = final_location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "authorization_code"), ("code", final_code), ("redirect_uri", redirect_uri), - ("code_verifier", &code_verifier2), ("client_id", &client_id)]) - .send().await.unwrap(); + .form(&[ + ("grant_type", "authorization_code"), + ("code", final_code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier2), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); assert_eq!(token_res.status(), StatusCode::OK); let final_token: Value = token_res.json().await.unwrap(); assert_eq!(final_token["sub"], user_did); @@ -459,7 +857,9 @@ async fn test_oauth_state_encoding() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) - .send().await.unwrap(); + .send() + .await + .unwrap(); let account: Value = create_res.json().await.unwrap(); verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let redirect_uri = "https://example.com/state-special-callback"; @@ -469,18 +869,378 @@ async fn test_oauth_state_encoding() { let special_state = "state=with&special=chars&plus+more"; 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"), ("state", special_state)]) - .send().await.unwrap().json().await.unwrap(); + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("state", special_state), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); - let auth_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")]) + .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(); - assert!(auth_res.status().is_redirection()); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Expected OK with JSON response" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .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(); + assert_eq!( + consent_res.status(), + StatusCode::OK, + "Consent should succeed" + ); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } assert!(location.contains("state=")); let encoded_state = urlencoding::encode(special_state); - assert!(location.contains(&format!("state={}", encoded_state)), "State should be URL-encoded. Got: {}", location); + assert!( + location.contains(&format!("state={}", encoded_state)), + "State should be URL-encoded. Got: {}", + location + ); +} + +async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) { + let url = base_url().await; + let http_client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("scope-test-{}", ts); + let email = format!("scope-test-{}@example.com", ts); + let password = "scope-test-password"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ "handle": handle, "email": email, "password": password })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap().to_string(); + verify_new_account(&http_client, &user_did).await; + let redirect_uri = "https://example.com/scope-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + let par_res = 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"), + ("scope", scope), + ("state", "test"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + par_res.status(), + StatusCode::CREATED, + "PAR should succeed for scope: {}", + scope + ); + let par_body: Value = par_res.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(); + assert_eq!(auth_res.status(), StatusCode::OK); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .to_string(); + if location.contains("/oauth/consent") { + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false})) + .send().await.unwrap(); + let consent_status = consent_res.status(); + let consent_body: Value = consent_res.json().await.unwrap(); + assert_eq!( + consent_status, + StatusCode::OK, + "Consent should succeed. Scope: {}, Body: {:?}", + scope, + consent_body + ); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + let token_res = 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(); + assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); + let token_body: Value = token_res.json().await.unwrap(); + let access_token = token_body["access_token"].as_str().unwrap().to_string(); + (access_token, user_did, handle) +} + +#[tokio::test] +async fn test_granular_scope_repo_create_only() { + let url = base_url().await; + let http_client = client(); + let (token, did, _) = + get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await; + let now = chrono::Utc::now().to_rfc3339(); + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&token) + .json(&json!({ + "repo": &did, + "collection": "app.bsky.feed.post", + "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now } + })) + .send() + .await + .unwrap(); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Should allow creating posts with repo:app.bsky.feed.post?action=create" + ); + let body: Value = create_res.json().await.unwrap(); + let uri = body["uri"].as_str().expect("Should have uri"); + let rkey = uri.split('/').last().unwrap(); + let delete_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) + .bearer_auth(&token) + .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) + .send() + .await + .unwrap(); + assert_eq!( + delete_res.status(), + StatusCode::FORBIDDEN, + "Should NOT allow deleting with create-only scope" + ); + let like_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&token) + .json(&json!({ + "repo": &did, + "collection": "app.bsky.feed.like", + "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now } + })) + .send().await.unwrap(); + assert_eq!( + like_res.status(), + StatusCode::FORBIDDEN, + "Should NOT allow creating likes (wrong collection)" + ); +} + +#[tokio::test] +async fn test_granular_scope_wildcard_collection() { + let url = base_url().await; + let http_client = client(); + let (token, did, _) = get_oauth_token_with_scope( + "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*", + ) + .await; + let now = chrono::Utc::now().to_rfc3339(); + let post_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&token) + .json(&json!({ + "repo": &did, + "collection": "app.bsky.feed.post", + "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now } + })) + .send() + .await + .unwrap(); + assert_eq!( + post_res.status(), + StatusCode::OK, + "Should allow app.bsky.feed.post with app.bsky.* scope" + ); + let body: Value = post_res.json().await.unwrap(); + let uri = body["uri"].as_str().unwrap(); + let rkey = uri.split('/').last().unwrap(); + let delete_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) + .bearer_auth(&token) + .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) + .send() + .await + .unwrap(); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "Should allow delete with action=delete" + ); + let other_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&token) + .json(&json!({ + "repo": &did, + "collection": "com.example.record", + "record": { "$type": "com.example.record", "data": "test", "createdAt": now } + })) + .send() + .await + .unwrap(); + assert_eq!( + other_res.status(), + StatusCode::FORBIDDEN, + "Should NOT allow com.example.* with app.bsky.* scope" + ); +} + +#[tokio::test] +async fn test_granular_scope_email_read() { + let url = base_url().await; + let http_client = client(); + let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await; + let session_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(session_res.status(), StatusCode::OK); + let body: Value = session_res.json().await.unwrap(); + assert_eq!(body["did"], did); + assert!( + body["email"].is_string(), + "Email should be visible with account:email?action=read. Got: {:?}", + body + ); +} + +#[tokio::test] +async fn test_granular_scope_no_email_access() { + let url = base_url().await; + let http_client = client(); + let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await; + let session_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(session_res.status(), StatusCode::OK); + let body: Value = session_res.json().await.unwrap(); + assert_eq!(body["did"], did); + assert!( + body["email"].is_null() || body.get("email").is_none(), + "Email should be hidden without account:email scope. Got: {:?}", + body["email"] + ); +} + +#[tokio::test] +async fn test_granular_scope_rpc_specific_method() { + let url = base_url().await; + let http_client = client(); + let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await; + let allowed_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) + .bearer_auth(&token) + .query(&[ + ("aud", "did:web:api.bsky.app"), + ("lxm", "app.bsky.feed.getTimeline"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + allowed_res.status(), + StatusCode::OK, + "Should allow getServiceAuth for app.bsky.feed.getTimeline" + ); + let body: Value = allowed_res.json().await.unwrap(); + assert!(body["token"].is_string(), "Should return service token"); + let blocked_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) + .bearer_auth(&token) + .query(&[ + ("aud", "did:web:api.bsky.app"), + ("lxm", "app.bsky.feed.getAuthorFeed"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + blocked_res.status(), + StatusCode::FORBIDDEN, + "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed" + ); + let blocked_body: Value = blocked_res.json().await.unwrap(); + assert!( + blocked_body["error"] + .as_str() + .unwrap_or("") + .contains("Scope") + || blocked_body["message"] + .as_str() + .unwrap_or("") + .contains("scope"), + "Should mention scope restriction: {:?}", + blocked_body + ); + let no_lxm_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) + .bearer_auth(&token) + .query(&[("aud", "did:web:api.bsky.app")]) + .send() + .await + .unwrap(); + assert_eq!( + no_lxm_res.status(), + StatusCode::BAD_REQUEST, + "Should require lxm parameter for granular scopes" + ); } diff --git a/tests/oauth_client_metadata.rs b/tests/oauth_client_metadata.rs index a59a5af..4839ac1 100644 --- a/tests/oauth_client_metadata.rs +++ b/tests/oauth_client_metadata.rs @@ -7,49 +7,85 @@ use serde_json::Value; async fn test_frontend_client_metadata_returns_valid_json() { let client = client(); let res = client - .get(format!( - "{}/oauth/client-metadata.json", - base_url().await - )) + .get(format!("{}/oauth/client-metadata.json", base_url().await)) .send() .await .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.expect("Should return valid JSON"); - assert!(body["client_id"].as_str().is_some(), "Should have client_id"); - assert!(body["client_name"].as_str().is_some(), "Should have client_name"); - assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris"); - assert!(body["grant_types"].as_array().is_some(), "Should have grant_types"); - assert!(body["response_types"].as_array().is_some(), "Should have response_types"); + assert!( + body["client_id"].as_str().is_some(), + "Should have client_id" + ); + assert!( + body["client_name"].as_str().is_some(), + "Should have client_name" + ); + assert!( + body["redirect_uris"].as_array().is_some(), + "Should have redirect_uris" + ); + assert!( + body["grant_types"].as_array().is_some(), + "Should have grant_types" + ); + assert!( + body["response_types"].as_array().is_some(), + "Should have response_types" + ); assert!(body["scope"].as_str().is_some(), "Should have scope"); - assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method"); + assert!( + body["token_endpoint_auth_method"].as_str().is_some(), + "Should have token_endpoint_auth_method" + ); } #[tokio::test] async fn test_frontend_client_metadata_correct_values() { let client = client(); let res = client - .get(format!( - "{}/oauth/client-metadata.json", - base_url().await - )) + .get(format!("{}/oauth/client-metadata.json", base_url().await)) .send() .await .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let client_id = body["client_id"].as_str().unwrap(); - assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json"); + assert!( + client_id.ends_with("/oauth/client-metadata.json"), + "client_id should end with /oauth/client-metadata.json" + ); let grant_types = body["grant_types"].as_array().unwrap(); let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect(); - assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant"); - assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant"); + assert!( + grant_strs.contains(&"authorization_code"), + "Should support authorization_code grant" + ); + assert!( + grant_strs.contains(&"refresh_token"), + "Should support refresh_token grant" + ); let response_types = body["response_types"].as_array().unwrap(); let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect(); - assert!(response_strs.contains(&"code"), "Should support code response type"); - assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)"); - assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application"); - assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP"); + assert!( + response_strs.contains(&"code"), + "Should support code response type" + ); + assert_eq!( + body["token_endpoint_auth_method"].as_str(), + Some("none"), + "Should be public client (none auth)" + ); + assert_eq!( + body["application_type"].as_str(), + Some("web"), + "Should be web application" + ); + assert_eq!( + body["dpop_bound_access_tokens"].as_bool(), + Some(false), + "Should not require DPoP" + ); let scope = body["scope"].as_str().unwrap(); assert!(scope.contains("atproto"), "Scope should include atproto"); } @@ -58,10 +94,7 @@ async fn test_frontend_client_metadata_correct_values() { async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() { let client = client(); let res = client - .get(format!( - "{}/oauth/client-metadata.json", - base_url().await - )) + .get(format!("{}/oauth/client-metadata.json", base_url().await)) .send() .await .expect("Failed to send request"); @@ -69,7 +102,13 @@ async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() { let body: Value = res.json().await.unwrap(); let client_uri = body["client_uri"].as_str().unwrap(); let redirect_uris = body["redirect_uris"].as_array().unwrap(); - assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI"); + assert!( + !redirect_uris.is_empty(), + "Should have at least one redirect URI" + ); let redirect_uri = redirect_uris[0].as_str().unwrap(); - assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri"); + assert!( + redirect_uri.starts_with(client_uri), + "Redirect URI should be on same origin as client_uri" + ); } diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs index aa8e95a..651f58b 100644 --- a/tests/oauth_lifecycle.rs +++ b/tests/oauth_lifecycle.rs @@ -5,7 +5,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; use common::{base_url, client}; use helpers::verify_new_account; -use reqwest::{StatusCode, redirect}; +use reqwest::StatusCode; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path}; @@ -21,13 +21,6 @@ fn generate_pkce() -> (String, String) { (code_verifier, code_challenge) } -fn no_redirect_client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(redirect::Policy::none()) - .build() - .unwrap() -} - async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { let mock_server = MockServer::start().await; let client_id = mock_server.uri(); @@ -102,24 +95,46 @@ async fn create_user_and_oauth_session( ); let par_body: Value = par_res.json().await.unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); - let auth_client = no_redirect_client(); - let auth_res = auth_client + let auth_res = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[ - ("request_uri", request_uri), - ("username", &handle), - ("password", &password), - ("remember_device", "false"), - ]) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": &password, + "remember_device": false + })) .send() .await .expect("Authorize failed"); - let location = auth_res - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap(); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Authorize should return OK" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .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.expect("Consent request failed"); + assert_eq!( + consent_res.status(), + StatusCode::OK, + "Consent should succeed" + ); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } let code = location .split("code=") .nth(1) @@ -596,24 +611,31 @@ async fn test_oauth_multiple_clients_same_user() { .unwrap(); let par_body1: Value = par_res1.json().await.unwrap(); let request_uri1 = par_body1["request_uri"].as_str().unwrap(); - let auth_client = no_redirect_client(); - let auth_res1 = auth_client + let auth_res1 = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[ - ("request_uri", request_uri1), - ("username", &handle), - ("password", password), - ("remember_device", "false"), - ]) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri1, + "username": &handle, + "password": password, + "remember_device": false + })) .send() .await .unwrap(); - let location1 = auth_res1 - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap(); + assert_eq!(auth_res1.status(), StatusCode::OK); + let auth_body1: Value = auth_res1.json().await.unwrap(); + let mut location1 = auth_body1["redirect_uri"].as_str().unwrap().to_string(); + if location1.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false})) + .send().await.unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location1 = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } let code1 = location1 .split("code=") .nth(1) @@ -650,23 +672,31 @@ async fn test_oauth_multiple_clients_same_user() { .unwrap(); let par_body2: Value = par_res2.json().await.unwrap(); let request_uri2 = par_body2["request_uri"].as_str().unwrap(); - let auth_res2 = auth_client + let auth_res2 = http_client .post(format!("{}/oauth/authorize", url)) - .form(&[ - ("request_uri", request_uri2), - ("username", &handle), - ("password", password), - ("remember_device", "false"), - ]) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri2, + "username": &handle, + "password": password, + "remember_device": false + })) .send() .await .unwrap(); - let location2 = auth_res2 - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap(); + assert_eq!(auth_res2.status(), StatusCode::OK); + let auth_body2: Value = auth_res2.json().await.unwrap(); + let mut location2 = auth_body2["redirect_uri"].as_str().unwrap().to_string(); + if location2.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false})) + .send().await.unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location2 = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } let code2 = location2 .split("code=") .nth(1) diff --git a/tests/oauth_scopes.rs b/tests/oauth_scopes.rs new file mode 100644 index 0000000..b344fb4 --- /dev/null +++ b/tests/oauth_scopes.rs @@ -0,0 +1,753 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client}; +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 hash = hasher.finalize(); + let code_challenge = URL_SAFE_NO_PAD.encode(&hash); + (code_verifier, code_challenge) +} + +async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { + let mock_server = MockServer::start().await; + let client_id = mock_server.uri(); + let metadata = json!({ + "client_id": client_id, + "client_name": "Test OAuth Scope Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": false + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + mock_server +} + +struct OAuthSession { + access_token: String, + #[allow(dead_code)] + refresh_token: String, + did: String, + #[allow(dead_code)] + client_id: String, + scope: String, +} + +async fn create_user_and_oauth_session_with_scope( + handle_prefix: &str, + redirect_uri: &str, + scope: &str, +) -> (OAuthSession, MockServer) { + let url = base_url().await; + let http_client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("{}-{}", handle_prefix, ts); + let email = format!("{}-{}@example.com", handle_prefix, ts); + let password = format!("{}-password", handle_prefix); + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .expect("Account creation failed"); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap().to_string(); + + let _ = verify_new_account(&http_client, &user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = 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"), + ("scope", scope), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.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 + .expect("Authorize failed"); + assert_eq!( + auth_res.status(), + StatusCode::OK, + "Authorize should return OK" + ); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .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.expect("Consent request failed"); + assert_eq!( + consent_res.status(), + StatusCode::OK, + "Consent should succeed" + ); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = 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 + .expect("Token request failed"); + assert_eq!(token_res.status(), StatusCode::OK); + let token_body: Value = token_res.json().await.unwrap(); + + let session = OAuthSession { + access_token: token_body["access_token"].as_str().unwrap().to_string(), + refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), + did: user_did, + client_id, + scope: scope.to_string(), + }; + (session, mock_client) +} + +#[tokio::test] +async fn test_atproto_scope_allows_full_access() { + let url = base_url().await; + let http_client = client(); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-full", + "https://example.com/callback", + "atproto", + ) + .await; + + let collection = "app.bsky.feed.post"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Full access post", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!( + create_res.status(), + StatusCode::OK, + "atproto scope should allow creating records" + ); + let create_body: Value = create_res.json().await.unwrap(); + let rkey = create_body["uri"] + .as_str() + .unwrap() + .split('/') + .last() + .unwrap(); + + let put_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": "Updated post", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + assert_eq!( + put_res.status(), + StatusCode::OK, + "atproto scope should allow updating records" + ); + + let delete_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.did, + "collection": collection, + "rkey": rkey + })) + .send() + .await + .unwrap(); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "atproto scope should allow deleting records" + ); +} + +#[tokio::test] +async fn test_atproto_scope_allows_blob_upload() { + let url = base_url().await; + let http_client = client(); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-blob", + "https://example.com/callback", + "atproto", + ) + .await; + + let blob_data = b"Test blob data for scope test"; + let upload_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url)) + .bearer_auth(&session.access_token) + .header("Content-Type", "text/plain") + .body(blob_data.to_vec()) + .send() + .await + .unwrap(); + + assert_eq!( + upload_res.status(), + StatusCode::OK, + "atproto scope should allow blob upload" + ); + let upload_body: Value = upload_res.json().await.unwrap(); + assert!(upload_body["blob"]["ref"]["$link"].is_string()); +} + +#[tokio::test] +async fn test_atproto_scope_allows_batch_writes() { + let url = base_url().await; + let http_client = client(); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-batch", + "https://example.com/callback", + "atproto", + ) + .await; + + let collection = "app.bsky.feed.post"; + let now = Utc::now().to_rfc3339(); + let apply_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": collection, + "rkey": "batch-scope-1", + "value": { + "$type": collection, + "text": "Batch post 1", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": collection, + "rkey": "batch-scope-2", + "value": { + "$type": collection, + "text": "Batch post 2", + "createdAt": now + } + } + ] + })) + .send() + .await + .unwrap(); + + assert_eq!( + apply_res.status(), + StatusCode::OK, + "atproto scope should allow batch writes" + ); +} + +#[tokio::test] +async fn test_transition_generic_scope_allows_access() { + let url = base_url().await; + let http_client = client(); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-transition", + "https://example.com/callback", + "atproto transition:generic", + ) + .await; + + let collection = "app.bsky.feed.post"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Post with transition scope", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!( + create_res.status(), + StatusCode::OK, + "transition:generic scope combined with atproto should work" + ); +} + +#[tokio::test] +async fn test_consent_endpoint_returns_scope_info() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("consent-test-{}", ts); + let email = format!("consent-{}@example.com", ts); + let password = "consent-password"; + let redirect_uri = "https://consent-test.example.com/callback"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap(); + let _ = verify_new_account(&http_client, user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + + let par_res = 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"), + ("scope", "atproto transition:generic"), + ]) + .send() + .await + .unwrap(); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); + + let consent_res = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri)]) + .send() + .await + .unwrap(); + + assert_eq!(consent_res.status(), StatusCode::OK); + let consent_body: Value = consent_res.json().await.unwrap(); + + assert_eq!(consent_body["client_id"], client_id); + assert_eq!(consent_body["did"], user_did); + assert!(consent_body["scopes"].is_array()); + + let scopes = consent_body["scopes"].as_array().unwrap(); + assert!(!scopes.is_empty(), "Should have scopes in response"); + + let atproto_scope = scopes.iter().find(|s| s["scope"] == "atproto"); + assert!(atproto_scope.is_some(), "Should include atproto scope"); + let atproto = atproto_scope.unwrap(); + assert_eq!(atproto["required"], true, "atproto should be required"); + assert!(atproto["description"].is_string()); + assert!(atproto["display_name"].is_string()); + + let transition_scope = scopes.iter().find(|s| s["scope"] == "transition:generic"); + assert!( + transition_scope.is_some(), + "Should include transition:generic scope" + ); + let transition = transition_scope.unwrap(); + assert_eq!( + transition["required"], false, + "transition:generic should be optional" + ); +} + +#[tokio::test] +async fn test_consent_post_generates_code() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("consent-post-{}", ts); + let email = format!("consent-post-{}@example.com", ts); + let password = "consent-post-password"; + let redirect_uri = "https://consent-post.example.com/callback"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap(); + let _ = verify_new_account(&http_client, user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = 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"), + ("scope", "atproto"), + ]) + .send() + .await + .unwrap(); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); + + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + + assert_eq!(consent_post_res.status(), StatusCode::OK); + let consent_body: Value = consent_post_res.json().await.unwrap(); + assert!( + consent_body["redirect_uri"].is_string(), + "Should return redirect URI" + ); + + let redirect_uri_response = consent_body["redirect_uri"].as_str().unwrap(); + assert!( + redirect_uri_response.contains("code="), + "Redirect URI should contain authorization code" + ); + + let code = redirect_uri_response + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = 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(); + + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); + let token_body: Value = token_res.json().await.unwrap(); + assert!(token_body["access_token"].is_string()); +} + +#[tokio::test] +async fn test_consent_post_requires_atproto_scope() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("consent-req-{}", ts); + let email = format!("consent-req-{}@example.com", ts); + let password = "consent-req-password"; + let redirect_uri = "https://consent-req.example.com/callback"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap(); + let _ = verify_new_account(&http_client, user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + + let par_res = 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"), + ("scope", "atproto transition:generic"), + ]) + .send() + .await + .unwrap(); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); + + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["transition:generic"], + "remember": false + })) + .send() + .await + .unwrap(); + + assert_eq!( + consent_post_res.status(), + StatusCode::BAD_REQUEST, + "Should reject consent without atproto scope" + ); + let error_body: Value = consent_post_res.json().await.unwrap(); + assert!( + error_body["error_description"] + .as_str() + .unwrap() + .contains("atproto") + ); +} + +#[tokio::test] +async fn test_token_contains_requested_scope() { + let scope = "atproto transition:generic"; + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-token", + "https://example.com/callback", + scope, + ) + .await; + + assert_eq!( + session.scope, scope, + "Session should have the requested scope" + ); + + let parts: Vec<&str> = session.access_token.split('.').collect(); + assert_eq!(parts.len(), 3, "Token should be a valid JWT"); + + let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let payload: Value = serde_json::from_slice(&payload_json).unwrap(); + + assert!( + payload["scope"].is_string(), + "Token payload should contain scope" + ); + let token_scope = payload["scope"].as_str().unwrap(); + assert!( + token_scope.contains("atproto"), + "Token scope should contain atproto" + ); +} + +#[tokio::test] +async fn test_dereference_scope_endpoint() { + let url = base_url().await; + let http_client = client(); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "scope-deref", + "https://example.com/callback", + "atproto", + ) + .await; + + let deref_res = http_client + .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "scope": "atproto transition:generic" + })) + .send() + .await + .unwrap(); + + assert_eq!(deref_res.status(), StatusCode::OK); + let deref_body: Value = deref_res.json().await.unwrap(); + assert!(deref_body["scope"].is_string()); + let resolved_scope = deref_body["scope"].as_str().unwrap(); + assert!(resolved_scope.contains("atproto")); + assert!(resolved_scope.contains("transition:generic")); +} + +#[tokio::test] +async fn test_dereference_scope_requires_auth() { + let url = base_url().await; + let http_client = client(); + + let deref_res = http_client + .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url)) + .json(&json!({ + "scope": "atproto" + })) + .send() + .await + .unwrap(); + + assert_eq!( + deref_res.status(), + StatusCode::UNAUTHORIZED, + "Should require authentication" + ); +} diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index d40c0af..c9f2499 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -2,20 +2,16 @@ mod common; mod helpers; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint}; use chrono::Utc; use common::{base_url, client}; use helpers::verify_new_account; -use reqwest::{StatusCode, redirect}; +use reqwest::StatusCode; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -fn no_redirect_client() -> reqwest::Client { - reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap() -} - fn generate_pkce() -> (String, String) { let verifier_bytes: [u8; 32] = rand::random(); let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); @@ -36,9 +32,11 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": false }); - Mock::given(method("GET")).and(path("/")) + Mock::given(method("GET")) + .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) - .mount(&mock_server).await; + .mount(&mock_server) + .await; mock_server } @@ -55,23 +53,64 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, let mock_client = setup_mock_client_metadata(redirect_uri).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 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_client = no_redirect_client(); - let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", "security-test-password"), ("remember_device", "false")]) + 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": "security-test-password", "remember_device": false})) .send().await.unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - 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) + 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, + ) } #[tokio::test] @@ -83,33 +122,90 @@ async fn test_token_tampering_attacks() { assert_eq!(parts.len(), 3); let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]); let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&forged_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected"); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&forged_token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "Forged signature should be rejected" + ); let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); payload["sub"] = json!("did:plc:attacker"); let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&modified_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected"); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&modified_token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "Modified payload should be rejected" + ); let none_header = json!({ "alg": "none", "typ": "at+jwt" }); let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" }); - let none_token = format!("{}.{}.", URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()), - URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap())); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&none_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "alg=none should be rejected"); + let none_token = format!( + "{}.{}.", + URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()) + ); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&none_token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "alg=none should be rejected" + ); let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" }); - let rs256_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()), - URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 64])); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&rs256_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Algorithm substitution should be rejected"); + let rs256_token = format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), + URL_SAFE_NO_PAD.encode(&[1u8; 64]) + ); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&rs256_token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "Algorithm substitution should be rejected" + ); let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds", "iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" }); - let expired_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()), - URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32])); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&expired_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected"); + let expired_token = format!( + "{}.{}.{}", + URL_SAFE_NO_PAD + .encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), + URL_SAFE_NO_PAD.encode(&[1u8; 32]) + ); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&expired_token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "Expired token should be rejected" + ); } #[tokio::test] @@ -119,17 +215,46 @@ async fn test_pkce_security() { let redirect_uri = "https://example.com/pkce-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); - let res = http_client.post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), - ("code_challenge", "plain-text-challenge"), ("code_challenge_method", "plain")]) - .send().await.unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected"); + let res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", "plain-text-challenge"), + ("code_challenge_method", "plain"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "PKCE plain method should be rejected" + ); let body: Value = res.json().await.unwrap(); - assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("s256")); - let res = http_client.post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri)]) - .send().await.unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); + assert!( + body["error_description"] + .as_str() + .unwrap() + .to_lowercase() + .contains("s256") + ); + let res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ]) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Missing PKCE challenge should be rejected" + ); let ts = Utc::now().timestamp_millis(); let handle = format!("pkce-attack-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) @@ -139,22 +264,62 @@ async fn test_pkce_security() { verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let (_, code_challenge) = generate_pkce(); let (attacker_verifier, _) = 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 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_client = no_redirect_client(); - let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", "pkce-password"), ("remember_device", "false")]) + 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": "pkce-password", "remember_device": false})) .send().await.unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); - let token_res = http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), - ("code_verifier", &attacker_verifier), ("client_id", &client_id)]) - .send().await.unwrap(); - assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected"); + assert_eq!(auth_res.status(), StatusCode::OK); + 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_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &attacker_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + assert_eq!( + token_res.status(), + StatusCode::BAD_REQUEST, + "Wrong PKCE verifier should be rejected" + ); } #[tokio::test] @@ -172,44 +337,134 @@ async fn test_replay_attacks() { let mock_client = setup_mock_client_metadata(redirect_uri).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 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_client = no_redirect_client(); - let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", request_uri), ("username", &handle), ("password", "replay-password"), ("remember_device", "false")]) - .send().await.unwrap(); - let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); - let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap().to_string(); - let first = 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)]) + 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": "replay-password", "remember_device": false})) .send().await.unwrap(); + assert_eq!(auth_res.status(), StatusCode::OK); + 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() + .to_string(); + let first = 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(); assert_eq!(first.status(), StatusCode::OK, "First use should succeed"); let first_body: Value = first.json().await.unwrap(); - let replay = 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(); - assert_eq!(replay.status(), StatusCode::BAD_REQUEST, "Auth code replay should fail"); + let replay = 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(); + assert_eq!( + replay.status(), + StatusCode::BAD_REQUEST, + "Auth code replay should fail" + ); let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string(); - let first_refresh: Value = http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)]) - .send().await.unwrap().json().await.unwrap(); - assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); + let first_refresh: Value = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &stolen_rt), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + first_refresh["access_token"].is_string(), + "First refresh should succeed" + ); let new_rt = first_refresh["refresh_token"].as_str().unwrap(); - let rt_replay = http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)]) - .send().await.unwrap(); - assert_eq!(rt_replay.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail"); + let rt_replay = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &stolen_rt), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + assert_eq!( + rt_replay.status(), + StatusCode::BAD_REQUEST, + "Refresh token replay should fail" + ); let body: Value = rt_replay.json().await.unwrap(); - assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("reuse")); - let family_revoked = http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "refresh_token"), ("refresh_token", new_rt), ("client_id", &client_id)]) - .send().await.unwrap(); - assert_eq!(family_revoked.status(), StatusCode::BAD_REQUEST, "Token family should be revoked"); + assert!( + body["error_description"] + .as_str() + .unwrap() + .to_lowercase() + .contains("reuse") + ); + let family_revoked = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", new_rt), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + assert_eq!( + family_revoked.status(), + StatusCode::BAD_REQUEST, + "Token family should be revoked" + ); } #[tokio::test] @@ -220,11 +475,23 @@ async fn test_oauth_security_boundaries() { let mock_client = setup_mock_client_metadata(registered_redirect).await; let client_id = mock_client.uri(); let (_, code_challenge) = generate_pkce(); - let res = http_client.post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", "https://attacker.com/steal"), - ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) - .send().await.unwrap(); - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected"); + let res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", "https://attacker.com/steal"), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Unregistered redirect_uri should be rejected" + ); let ts = Utc::now().timestamp_millis(); let handle = format!("deact-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) @@ -232,17 +499,38 @@ async fn test_oauth_security_boundaries() { .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; - http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) - .bearer_auth(&access_jwt).json(&json!({})).send().await.unwrap(); - let deact_par: Value = http_client.post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", registered_redirect), - ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) - .send().await.unwrap().json().await.unwrap(); + http_client + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) + .bearer_auth(&access_jwt) + .json(&json!({})) + .send() + .await + .unwrap(); + let deact_par: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", registered_redirect), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let auth_res = http_client.post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") .header("Accept", "application/json") - .form(&[("request_uri", deact_par["request_uri"].as_str().unwrap()), ("username", &handle), ("password", "deact-password"), ("remember_device", "false")]) + .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false})) .send().await.unwrap(); - assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked"); + assert_eq!( + auth_res.status(), + StatusCode::FORBIDDEN, + "Deactivated account should be blocked" + ); let redirect_uri_a = "https://app-a.com/callback"; let mock_a = setup_mock_client_metadata(redirect_uri_a).await; let client_id_a = mock_a.uri(); @@ -256,58 +544,173 @@ async fn test_oauth_security_boundaries() { let account2: Value = create_res2.json().await.unwrap(); verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; let (code_verifier2, code_challenge2) = generate_pkce(); - let par_a: Value = http_client.post(format!("{}/oauth/par", url)) - .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a), - ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")]) - .send().await.unwrap().json().await.unwrap(); - let auth_client = no_redirect_client(); - let auth_a = auth_client.post(format!("{}/oauth/authorize", url)) - .form(&[("request_uri", par_a["request_uri"].as_str().unwrap()), ("username", &handle2), ("password", "cross-password"), ("remember_device", "false")]) + let par_a: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id_a), + ("redirect_uri", redirect_uri_a), + ("code_challenge", &code_challenge2), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri_a = par_a["request_uri"].as_str().unwrap(); + let auth_a = http_client.post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false})) .send().await.unwrap(); - let loc_a = auth_a.headers().get("location").unwrap().to_str().unwrap(); - let code_a = loc_a.split("code=").nth(1).unwrap().split('&').next().unwrap(); - let cross_client = http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", "authorization_code"), ("code", code_a), ("redirect_uri", redirect_uri_a), - ("code_verifier", &code_verifier2), ("client_id", &client_id_b)]) - .send().await.unwrap(); - assert_eq!(cross_client.status(), StatusCode::BAD_REQUEST, "Cross-client code exchange must be rejected"); + assert_eq!(auth_a.status(), StatusCode::OK); + let auth_body_a: Value = auth_a.json().await.unwrap(); + let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string(); + if loc_a.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_a, "approved_scopes": ["atproto"], "remember": false})) + .send().await.unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + let code_a = loc_a + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + let cross_client = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code_a), + ("redirect_uri", redirect_uri_a), + ("code_verifier", &code_verifier2), + ("client_id", &client_id_b), + ]) + .send() + .await + .unwrap(); + assert_eq!( + cross_client.status(), + StatusCode::BAD_REQUEST, + "Cross-client code exchange must be rejected" + ); } #[tokio::test] async fn test_malformed_tokens_and_headers() { let url = base_url().await; let http_client = client(); - let malformed = vec!["", "not-a-token", "one.two", "one.two.three.four", "....", "eyJhbGciOiJIUzI1NiJ9", - "eyJhbGciOiJIUzI1NiJ9.", "eyJhbGciOiJIUzI1NiJ9..", ".eyJzdWIiOiJ0ZXN0In0.", "!!invalid!!.eyJ9.sig"]; + let malformed = vec![ + "", + "not-a-token", + "one.two", + "one.two.three.four", + "....", + "eyJhbGciOiJIUzI1NiJ9", + "eyJhbGciOiJIUzI1NiJ9.", + "eyJhbGciOiJIUzI1NiJ9..", + ".eyJzdWIiOiJ0ZXN0In0.", + "!!invalid!!.eyJ9.sig", + ]; for token in &malformed { - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); } let wrong_types = vec!["JWT", "jwt", "at+JWT", ""]; for typ in wrong_types { let header = json!({ "alg": "HS256", "typ": typ }); let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" }); - let 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])); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .bearer_auth(&token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "typ='{}' should be rejected", typ); + let 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]) + ); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&token) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "typ='{}' should be rejected", + typ + ); } let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; - let invalid_formats = vec![format!("Basic {}", access_token), format!("Digest {}", access_token), - access_token.clone(), format!("Bearer{}", access_token)]; + let invalid_formats = vec![ + format!("Basic {}", access_token), + format!("Digest {}", access_token), + access_token.clone(), + format!("Bearer{}", access_token), + ]; for auth in &invalid_formats { - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .header("Authorization", auth).send().await.unwrap().status(), StatusCode::UNAUTHORIZED); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", auth) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); } - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .send().await.unwrap().status(), StatusCode::UNAUTHORIZED); - assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) - .header("Authorization", "").send().await.unwrap().status(), StatusCode::UNAUTHORIZED); - let grants = vec!["client_credentials", "password", "implicit", "", "AUTHORIZATION_CODE"]; + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", "") + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + let grants = vec![ + "client_credentials", + "password", + "implicit", + "", + "AUTHORIZATION_CODE", + ]; for grant in grants { - assert_eq!(http_client.post(format!("{}/oauth/token", url)) - .form(&[("grant_type", grant), ("client_id", "https://example.com")]) - .send().await.unwrap().status(), StatusCode::BAD_REQUEST, "Grant '{}' should be rejected", grant); + assert_eq!( + http_client + .post(format!("{}/oauth/token", url)) + .form(&[("grant_type", grant), ("client_id", "https://example.com")]) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST, + "Grant '{}' should be rejected", + grant + ); } } @@ -316,14 +719,38 @@ async fn test_token_revocation() { let url = base_url().await; let http_client = client(); let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await; - assert_eq!(http_client.post(format!("{}/oauth/revoke", url)) - .form(&[("token", &refresh_token)]).send().await.unwrap().status(), StatusCode::OK); - let introspect: Value = http_client.post(format!("{}/oauth/introspect", url)) - .form(&[("token", &access_token)]).send().await.unwrap().json().await.unwrap(); - assert_eq!(introspect["active"], false, "Revoked token should be inactive"); + assert_eq!( + http_client + .post(format!("{}/oauth/revoke", url)) + .form(&[("token", &refresh_token)]) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + let introspect: Value = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", &access_token)]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + introspect["active"], false, + "Revoked token should be inactive" + ); } -fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option<&str>, iat_offset: i64) -> String { +fn create_dpop_proof( + method: &str, + uri: &str, + _nonce: Option<&str>, + ath: Option<&str>, + iat_offset: i64, +) -> String { use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use p256::elliptic_curve::sec1::ToEncodedPoint; let signing_key = SigningKey::random(&mut rand::thread_rng()); @@ -333,12 +760,18 @@ fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option< let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset }); - if let Some(a) = ath { payload["ath"] = json!(a); } + if let Some(a) = ath { + payload["ath"] = json!(a); + } 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: Signature = signing_key.sign(signing_input.as_bytes()); - format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())) + format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ) } #[test] @@ -350,11 +783,20 @@ fn test_dpop_nonce_security() { let nonce = v1.generate_nonce(); assert!(!nonce.is_empty()); assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass"); - assert!(v2.validate_nonce(&nonce).is_err(), "Nonce from different secret should fail"); + assert!( + v2.validate_nonce(&nonce).is_err(), + "Nonce from different secret should fail" + ); let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); let mut tampered = nonce_bytes.clone(); - if !tampered.is_empty() { tampered[0] ^= 0xFF; } - assert!(v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)).is_err(), "Tampered nonce should fail"); + if !tampered.is_empty() { + tampered[0] ^= 0xFF; + } + assert!( + v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)) + .is_err(), + "Tampered nonce should fail" + ); assert!(v1.validate_nonce("invalid").is_err()); assert!(v1.validate_nonce("").is_err()); assert!(v1.validate_nonce("!!!not-base64!!!").is_err()); @@ -364,20 +806,79 @@ fn test_dpop_nonce_security() { fn test_dpop_proof_validation() { let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); - assert!(verifier.verify_proof("not.enough", "POST", "https://example.com", None).is_err()); - assert!(verifier.verify_proof("invalid", "POST", "https://example.com", None).is_err()); + assert!( + verifier + .verify_proof("not.enough", "POST", "https://example.com", None) + .is_err() + ); + assert!( + verifier + .verify_proof("invalid", "POST", "https://example.com", None) + .is_err() + ); let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); - assert!(verifier.verify_proof(&proof, "GET", "https://example.com/token", None).is_err(), "Method mismatch"); - assert!(verifier.verify_proof(&proof, "POST", "https://other.com/token", None).is_err(), "URI mismatch"); - assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None).is_ok(), "Query params should be ignored"); + assert!( + verifier + .verify_proof(&proof, "GET", "https://example.com/token", None) + .is_err(), + "Method mismatch" + ); + assert!( + verifier + .verify_proof(&proof, "POST", "https://other.com/token", None) + .is_err(), + "URI mismatch" + ); + assert!( + verifier + .verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None) + .is_ok(), + "Query params should be ignored" + ); let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600); - assert!(verifier.verify_proof(&old_proof, "POST", "https://example.com/token", None).is_err(), "iat too old"); + assert!( + verifier + .verify_proof(&old_proof, "POST", "https://example.com/token", None) + .is_err(), + "iat too old" + ); let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600); - assert!(verifier.verify_proof(&future_proof, "POST", "https://example.com/token", None).is_err(), "iat in future"); - let ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, Some("wrong"), 0); - assert!(verifier.verify_proof(&ath_proof, "GET", "https://example.com/resource", Some("correct")).is_err(), "ath mismatch"); + assert!( + verifier + .verify_proof(&future_proof, "POST", "https://example.com/token", None) + .is_err(), + "iat in future" + ); + let ath_proof = create_dpop_proof( + "GET", + "https://example.com/resource", + None, + Some("wrong"), + 0, + ); + assert!( + verifier + .verify_proof( + &ath_proof, + "GET", + "https://example.com/resource", + Some("correct") + ) + .is_err(), + "ath mismatch" + ); let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0); - assert!(verifier.verify_proof(&no_ath_proof, "GET", "https://example.com/resource", Some("expected")).is_err(), "Missing ath"); + assert!( + verifier + .verify_proof( + &no_ath_proof, + "GET", + "https://example.com/resource", + Some("expected") + ) + .is_err(), + "Missing ath" + ); } #[test] @@ -398,8 +899,17 @@ fn test_dpop_proof_signature_attacks() { let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); let signing_input = format!("{}.{}", header_b64, payload_b64); let signature: Signature = signing_key.sign(signing_input.as_bytes()); - let mismatched = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); - assert!(verifier.verify_proof(&mismatched, "POST", "https://example.com/token", None).is_err(), "Mismatched key should fail"); + let mismatched = format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ); + assert!( + verifier + .verify_proof(&mismatched, "POST", "https://example.com/token", None) + .is_err(), + "Mismatched key should fail" + ); let point = signing_key.verifying_key().to_encoded_point(false); let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } }); @@ -409,26 +919,80 @@ fn test_dpop_proof_signature_attacks() { let mut sig_bytes = good_sig.to_bytes().to_vec(); sig_bytes[0] ^= 0xFF; let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes)); - assert!(verifier.verify_proof(&tampered, "POST", "https://example.com/token", None).is_err(), "Tampered sig should fail"); + assert!( + verifier + .verify_proof(&tampered, "POST", "https://example.com/token", None) + .is_err(), + "Tampered sig should fail" + ); } #[test] fn test_jwk_thumbprint() { - let jwk = DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), - y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()) }; + y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()), + }; let tp1 = compute_jwk_thumbprint(&jwk).unwrap(); let tp2 = compute_jwk_thumbprint(&jwk).unwrap(); assert_eq!(tp1, tp2, "Thumbprint should be deterministic"); assert!(!tp1.is_empty()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("secp256k1".to_string()), - x: Some("x".to_string()), y: Some("y".to_string()) }).is_ok()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "OKP".to_string(), crv: Some("Ed25519".to_string()), - x: Some("x".to_string()), y: None }).is_ok()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: None, x: Some("x".to_string()), y: Some("y".to_string()) }).is_err()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: None, y: Some("y".to_string()) }).is_err()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: Some("x".to_string()), y: None }).is_err()); - assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "RSA".to_string(), crv: None, x: None, y: None }).is_err()); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "EC".to_string(), + crv: Some("secp256k1".to_string()), + x: Some("x".to_string()), + y: Some("y".to_string()) + }) + .is_ok() + ); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "OKP".to_string(), + crv: Some("Ed25519".to_string()), + x: Some("x".to_string()), + y: None + }) + .is_ok() + ); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "EC".to_string(), + crv: None, + x: Some("x".to_string()), + y: Some("y".to_string()) + }) + .is_err() + ); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: None, + y: Some("y".to_string()) + }) + .is_err() + ); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: Some("x".to_string()), + y: None + }) + .is_err() + ); + assert!( + compute_jwk_thumbprint(&DPoPJwk { + kty: "RSA".to_string(), + crv: None, + x: None, + y: None + }) + .is_err() + ); } #[test] @@ -437,7 +1001,15 @@ fn test_dpop_clock_skew() { use p256::elliptic_curve::sec1::ToEncodedPoint; let secret = b"test-dpop-secret-32-bytes-long!!"; let verifier = DPoPVerifier::new(secret); - let test_cases = vec![(-600, true), (-301, true), (-299, false), (0, false), (299, false), (301, true), (600, true)]; + let test_cases = vec![ + (-600, true), + (-301, true), + (-299, false), + (0, false), + (299, false), + (301, true), + (600, true), + ]; for (offset, should_fail) in test_cases { let signing_key = SigningKey::random(&mut rand::thread_rng()); let point = signing_key.verifying_key().to_encoded_point(false); @@ -450,10 +1022,17 @@ fn test_dpop_clock_skew() { let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); let signing_input = format!("{}.{}", header_b64, payload_b64); let signature: Signature = signing_key.sign(signing_input.as_bytes()); - let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); + let proof = format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ); let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); - if should_fail { assert!(result.is_err(), "offset {} should fail", offset); } - else { assert!(result.is_ok(), "offset {} should pass", offset); } + if should_fail { + assert!(result.is_err(), "offset {} should fail", offset); + } else { + assert!(result.is_ok(), "offset {} should pass", offset); + } } } @@ -474,6 +1053,15 @@ fn test_dpop_http_method_case() { let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); let signing_input = format!("{}.{}", header_b64, payload_b64); let signature: Signature = signing_key.sign(signing_input.as_bytes()); - let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); - assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token", None).is_ok(), "HTTP method should be case-insensitive"); + let proof = format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ); + assert!( + verifier + .verify_proof(&proof, "POST", "https://example.com/token", None) + .is_ok(), + "HTTP method should be case-insensitive" + ); } diff --git a/tests/plc_operations.rs b/tests/plc_operations.rs index 2b5abbd..fbdb49c 100644 --- a/tests/plc_operations.rs +++ b/tests/plc_operations.rs @@ -7,18 +7,45 @@ use sqlx::PgPool; #[tokio::test] async fn test_plc_operation_auth() { let client = client(); - let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await)) - .send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.requestPlcOperationSignature", + base_url().await + )) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await)) - .json(&json!({})).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.signPlcOperation", + base_url().await + )) + .json(&json!({})) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) - .json(&json!({ "operation": {} })).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.submitPlcOperation", + base_url().await + )) + .json(&json!({ "operation": {} })) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); let (token, _) = create_account_and_login(&client).await; - let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await)) - .bearer_auth(&token).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.requestPlcOperationSignature", + base_url().await + )) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); } @@ -26,13 +53,29 @@ async fn test_plc_operation_auth() { async fn test_sign_plc_operation_validation() { let client = client(); let (token, _) = create_account_and_login(&client).await; - let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await)) - .bearer_auth(&token).json(&json!({})).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.signPlcOperation", + base_url().await + )) + .bearer_auth(&token) + .json(&json!({})) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "InvalidRequest"); - let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await)) - .bearer_auth(&token).json(&json!({ "token": "invalid-token-12345" })).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.signPlcOperation", + base_url().await + )) + .bearer_auth(&token) + .json(&json!({ "token": "invalid-token-12345" })) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"] == "InvalidToken" || body["error"] == "ExpiredToken"); @@ -42,17 +85,34 @@ async fn test_sign_plc_operation_validation() { async fn test_submit_plc_operation_validation() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port())); - let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) - .bearer_auth(&token).json(&json!({ "operation": { "type": "invalid_type" } })).send().await.unwrap(); + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port())); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.submitPlcOperation", + base_url().await + )) + .bearer_auth(&token) + .json(&json!({ "operation": { "type": "invalid_type" } })) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "InvalidRequest"); - let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) - .bearer_auth(&token).json(&json!({ + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.submitPlcOperation", + base_url().await + )) + .bearer_auth(&token) + .json(&json!({ "operation": { "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "prev": null } - })).send().await.unwrap(); + })) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let handle = did.split(':').last().unwrap_or("user"); let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) @@ -75,7 +135,13 @@ async fn test_submit_plc_operation_validation() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "InvalidRequest"); - assert!(body["message"].as_str().unwrap_or("").contains("signing key") || body["message"].as_str().unwrap_or("").contains("rotation")); + assert!( + body["message"] + .as_str() + .unwrap_or("") + .contains("signing key") + || body["message"].as_str().unwrap_or("").contains("rotation") + ); let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await)) .bearer_auth(&token).json(&json!({ "operation": { "type": "plc_operation", "rotationKeys": ["did:key:z123"], @@ -100,8 +166,15 @@ async fn test_submit_plc_operation_validation() { async fn test_plc_token_lifecycle() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await)) - .bearer_auth(&token).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.requestPlcOperationSignature", + base_url().await + )) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); let db_url = get_db_connection_string().await; let pool = PgPool::connect(&db_url).await.unwrap(); @@ -113,12 +186,25 @@ async fn test_plc_token_lifecycle() { let row = row.unwrap(); assert_eq!(row.token.len(), 11, "Token should be in format xxxxx-xxxxx"); assert!(row.token.contains('-'), "Token should contain hyphen"); - assert!(row.expires_at > chrono::Utc::now(), "Token should not be expired"); + assert!( + row.expires_at > chrono::Utc::now(), + "Token should not be expired" + ); let diff = row.expires_at - chrono::Utc::now(); - assert!(diff.num_minutes() >= 9 && diff.num_minutes() <= 11, "Token should expire in ~10 minutes"); + assert!( + diff.num_minutes() >= 9 && diff.num_minutes() <= 11, + "Token should expire in ~10 minutes" + ); let token1 = row.token.clone(); - let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await)) - .bearer_auth(&token).send().await.unwrap(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.requestPlcOperationSignature", + base_url().await + )) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), StatusCode::OK); let token2 = sqlx::query_scalar!( "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", did diff --git a/tests/plc_validation.rs b/tests/plc_validation.rs index 07af247..c829d66 100644 --- a/tests/plc_validation.rs +++ b/tests/plc_validation.rs @@ -1,11 +1,11 @@ +use k256::ecdsa::SigningKey; +use serde_json::json; +use std::collections::HashMap; use tranquil_pds::plc::{ PlcError, PlcOperation, PlcService, PlcValidationContext, cid_for_cbor, sign_operation, signing_key_to_did_key, validate_plc_operation, validate_plc_operation_for_submission, verify_operation_signature, }; -use k256::ecdsa::SigningKey; -use serde_json::json; -use std::collections::HashMap; fn create_valid_operation() -> serde_json::Value { let key = SigningKey::random(&mut rand::thread_rng()); @@ -32,27 +32,44 @@ fn test_plc_operation_basic_validation() { assert!(validate_plc_operation(&op).is_ok()); let missing_type = json!({ "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "sig": "test" }); - assert!(matches!(validate_plc_operation(&missing_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing type"))); + assert!( + matches!(validate_plc_operation(&missing_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing type")) + ); let invalid_type = json!({ "type": "invalid_type", "sig": "test" }); - assert!(matches!(validate_plc_operation(&invalid_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Invalid type"))); + assert!( + matches!(validate_plc_operation(&invalid_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Invalid type")) + ); let missing_sig = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {} }); - assert!(matches!(validate_plc_operation(&missing_sig), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing sig"))); + assert!( + matches!(validate_plc_operation(&missing_sig), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing sig")) + ); let missing_rotation = json!({ "type": "plc_operation", "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "sig": "test" }); - assert!(matches!(validate_plc_operation(&missing_rotation), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotationKeys"))); + assert!( + matches!(validate_plc_operation(&missing_rotation), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotationKeys")) + ); let missing_verification = json!({ "type": "plc_operation", "rotationKeys": [], "alsoKnownAs": [], "services": {}, "sig": "test" }); - assert!(matches!(validate_plc_operation(&missing_verification), Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods"))); + assert!( + matches!(validate_plc_operation(&missing_verification), Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods")) + ); let missing_aka = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "services": {}, "sig": "test" }); - assert!(matches!(validate_plc_operation(&missing_aka), Err(PlcError::InvalidResponse(msg)) if msg.contains("alsoKnownAs"))); + assert!( + matches!(validate_plc_operation(&missing_aka), Err(PlcError::InvalidResponse(msg)) if msg.contains("alsoKnownAs")) + ); let missing_services = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "sig": "test" }); - assert!(matches!(validate_plc_operation(&missing_services), Err(PlcError::InvalidResponse(msg)) if msg.contains("services"))); + assert!( + matches!(validate_plc_operation(&missing_services), Err(PlcError::InvalidResponse(msg)) if msg.contains("services")) + ); - assert!(matches!(validate_plc_operation(&json!("not an object")), Err(PlcError::InvalidResponse(_)))); + assert!(matches!( + validate_plc_operation(&json!("not an object")), + Err(PlcError::InvalidResponse(_)) + )); } #[test] @@ -61,14 +78,20 @@ fn test_plc_submission_validation() { let did_key = signing_key_to_did_key(&key); let server_key = "did:key:zServer123"; - let base_op = |rotation_key: &str, signing_key: &str, handle: &str, service_type: &str, endpoint: &str| json!({ - "type": "plc_operation", - "rotationKeys": [rotation_key], - "verificationMethods": {"atproto": signing_key}, - "alsoKnownAs": [format!("at://{}", handle)], - "services": { "atproto_pds": { "type": service_type, "endpoint": endpoint } }, - "sig": "test" - }); + let base_op = |rotation_key: &str, + signing_key: &str, + handle: &str, + service_type: &str, + endpoint: &str| { + json!({ + "type": "plc_operation", + "rotationKeys": [rotation_key], + "verificationMethods": {"atproto": signing_key}, + "alsoKnownAs": [format!("at://{}", handle)], + "services": { "atproto_pds": { "type": service_type, "endpoint": endpoint } }, + "sig": "test" + }) + }; let ctx = PlcValidationContext { server_rotation_key: server_key.to_string(), @@ -77,8 +100,16 @@ fn test_plc_submission_validation() { expected_pds_endpoint: "https://pds.example.com".to_string(), }; - let op = base_op(&did_key, &did_key, "test.handle", "AtprotoPersonalDataServer", "https://pds.example.com"); - assert!(matches!(validate_plc_operation_for_submission(&op, &ctx), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotation key"))); + let op = base_op( + &did_key, + &did_key, + "test.handle", + "AtprotoPersonalDataServer", + "https://pds.example.com", + ); + assert!( + matches!(validate_plc_operation_for_submission(&op, &ctx), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotation key")) + ); let ctx_with_user_key = PlcValidationContext { server_rotation_key: did_key.clone(), @@ -87,17 +118,49 @@ fn test_plc_submission_validation() { expected_pds_endpoint: "https://pds.example.com".to_string(), }; - let wrong_signing = base_op(&did_key, "did:key:zWrongKey", "test.handle", "AtprotoPersonalDataServer", "https://pds.example.com"); - assert!(matches!(validate_plc_operation_for_submission(&wrong_signing, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("signing key"))); + let wrong_signing = base_op( + &did_key, + "did:key:zWrongKey", + "test.handle", + "AtprotoPersonalDataServer", + "https://pds.example.com", + ); + assert!( + matches!(validate_plc_operation_for_submission(&wrong_signing, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("signing key")) + ); - let wrong_handle = base_op(&did_key, &did_key, "wrong.handle", "AtprotoPersonalDataServer", "https://pds.example.com"); - assert!(matches!(validate_plc_operation_for_submission(&wrong_handle, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("handle"))); + let wrong_handle = base_op( + &did_key, + &did_key, + "wrong.handle", + "AtprotoPersonalDataServer", + "https://pds.example.com", + ); + assert!( + matches!(validate_plc_operation_for_submission(&wrong_handle, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("handle")) + ); - let wrong_service_type = base_op(&did_key, &did_key, "test.handle", "WrongServiceType", "https://pds.example.com"); - assert!(matches!(validate_plc_operation_for_submission(&wrong_service_type, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("type"))); + let wrong_service_type = base_op( + &did_key, + &did_key, + "test.handle", + "WrongServiceType", + "https://pds.example.com", + ); + assert!( + matches!(validate_plc_operation_for_submission(&wrong_service_type, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("type")) + ); - let wrong_endpoint = base_op(&did_key, &did_key, "test.handle", "AtprotoPersonalDataServer", "https://wrong.endpoint.com"); - assert!(matches!(validate_plc_operation_for_submission(&wrong_endpoint, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("endpoint"))); + let wrong_endpoint = base_op( + &did_key, + &did_key, + "test.handle", + "AtprotoPersonalDataServer", + "https://wrong.endpoint.com", + ); + assert!( + matches!(validate_plc_operation_for_submission(&wrong_endpoint, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("endpoint")) + ); } #[test] @@ -121,13 +184,18 @@ fn test_signature_verification() { assert!(result.is_ok() && !result.unwrap()); let missing_sig = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {} }); - assert!(matches!(verify_operation_signature(&missing_sig, &[]), Err(PlcError::InvalidResponse(msg)) if msg.contains("sig"))); + assert!( + matches!(verify_operation_signature(&missing_sig, &[]), Err(PlcError::InvalidResponse(msg)) if msg.contains("sig")) + ); let invalid_base64 = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "sig": "not-valid-base64!!!" }); - assert!(matches!(verify_operation_signature(&invalid_base64, &[]), Err(PlcError::InvalidResponse(_)))); + assert!(matches!( + verify_operation_signature(&invalid_base64, &[]), + Err(PlcError::InvalidResponse(_)) + )); } #[test] @@ -136,7 +204,10 @@ fn test_cid_and_key_utilities() { let cid1 = cid_for_cbor(&value).unwrap(); let cid2 = cid_for_cbor(&value).unwrap(); assert_eq!(cid1, cid2, "CID should be deterministic"); - assert!(cid1.starts_with("bafyrei"), "CID should be dag-cbor + sha256"); + assert!( + cid1.starts_with("bafyrei"), + "CID should be dag-cbor + sha256" + ); let value2 = json!({ "alpha": 999 }); let cid3 = cid_for_cbor(&value2).unwrap(); @@ -145,15 +216,24 @@ fn test_cid_and_key_utilities() { let key = SigningKey::random(&mut rand::thread_rng()); let did = signing_key_to_did_key(&key); assert!(did.starts_with("did:key:z") && did.len() > 50); - assert_eq!(did, signing_key_to_did_key(&key), "Same key should produce same did"); + assert_eq!( + did, + signing_key_to_did_key(&key), + "Same key should produce same did" + ); let key2 = SigningKey::random(&mut rand::thread_rng()); - assert_ne!(did, signing_key_to_did_key(&key2), "Different keys should produce different dids"); + assert_ne!( + did, + signing_key_to_did_key(&key2), + "Different keys should produce different dids" + ); } #[test] fn test_tombstone_operations() { - let tombstone = json!({ "type": "plc_tombstone", "prev": "bafyreig6xxxxxyyyyyzzzzzz", "sig": "test" }); + let tombstone = + json!({ "type": "plc_tombstone", "prev": "bafyreig6xxxxxyyyyyzzzzzz", "sig": "test" }); assert!(validate_plc_operation(&tombstone).is_ok()); let key = SigningKey::random(&mut rand::thread_rng()); @@ -175,13 +255,19 @@ fn test_sign_operation_and_struct() { "alsoKnownAs": [], "services": {}, "prev": null, "sig": "old_signature" }); let signed = sign_operation(&op, &key).unwrap(); - assert_ne!(signed.get("sig").and_then(|v| v.as_str()).unwrap(), "old_signature"); + assert_ne!( + signed.get("sig").and_then(|v| v.as_str()).unwrap(), + "old_signature" + ); let mut services = HashMap::new(); - services.insert("atproto_pds".to_string(), PlcService { - service_type: "AtprotoPersonalDataServer".to_string(), - endpoint: "https://pds.example.com".to_string(), - }); + services.insert( + "atproto_pds".to_string(), + PlcService { + service_type: "AtprotoPersonalDataServer".to_string(), + endpoint: "https://pds.example.com".to_string(), + }, + ); let mut verification_methods = HashMap::new(); verification_methods.insert("atproto".to_string(), "did:key:zTest123".to_string()); let op = PlcOperation { diff --git a/tests/record_validation.rs b/tests/record_validation.rs index 99cbdd5..692b759 100644 --- a/tests/record_validation.rs +++ b/tests/record_validation.rs @@ -1,8 +1,8 @@ +use serde_json::json; use tranquil_pds::validation::{ RecordValidator, ValidationError, ValidationStatus, validate_collection_nsid, validate_record_key, }; -use serde_json::json; fn now() -> String { chrono::Utc::now().to_rfc3339() @@ -17,33 +17,49 @@ fn test_post_record_validation() { "text": "Hello world!", "createdAt": now() }); - assert_eq!(validator.validate(&valid_post, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_post, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let missing_text = json!({ "$type": "app.bsky.feed.post", "createdAt": now() }); - assert!(matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text")); + assert!( + matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text") + ); let missing_created_at = json!({ "$type": "app.bsky.feed.post", "text": "Hello" }); - assert!(matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt")); + assert!( + matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt") + ); let text_too_long = json!({ "$type": "app.bsky.feed.post", "text": "a".repeat(3001), "createdAt": now() }); - assert!(matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text")); + assert!( + matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text") + ); let text_at_limit = json!({ "$type": "app.bsky.feed.post", "text": "a".repeat(3000), "createdAt": now() }); - assert_eq!(validator.validate(&text_at_limit, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&text_at_limit, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let too_many_langs = json!({ "$type": "app.bsky.feed.post", @@ -51,7 +67,9 @@ fn test_post_record_validation() { "createdAt": now(), "langs": ["en", "fr", "de", "es"] }); - assert!(matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs")); + assert!( + matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs") + ); let three_langs_ok = json!({ "$type": "app.bsky.feed.post", @@ -59,7 +77,12 @@ fn test_post_record_validation() { "createdAt": now(), "langs": ["en", "fr", "de"] }); - assert_eq!(validator.validate(&three_langs_ok, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&three_langs_ok, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let too_many_tags = json!({ "$type": "app.bsky.feed.post", @@ -67,7 +90,9 @@ fn test_post_record_validation() { "createdAt": now(), "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"] }); - assert!(matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags")); + assert!( + matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags") + ); let eight_tags_ok = json!({ "$type": "app.bsky.feed.post", @@ -75,7 +100,12 @@ fn test_post_record_validation() { "createdAt": now(), "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"] }); - assert_eq!(validator.validate(&eight_tags_ok, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&eight_tags_ok, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let tag_too_long = json!({ "$type": "app.bsky.feed.post", @@ -83,7 +113,9 @@ fn test_post_record_validation() { "createdAt": now(), "tags": ["t".repeat(641)] }); - assert!(matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))); + assert!( + matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")) + ); } #[test] @@ -95,24 +127,38 @@ fn test_profile_record_validation() { "displayName": "Test User", "description": "A test user profile" }); - assert_eq!(validator.validate(&valid, "app.bsky.actor.profile").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid, "app.bsky.actor.profile") + .unwrap(), + ValidationStatus::Valid + ); let empty_ok = json!({ "$type": "app.bsky.actor.profile" }); - assert_eq!(validator.validate(&empty_ok, "app.bsky.actor.profile").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&empty_ok, "app.bsky.actor.profile") + .unwrap(), + ValidationStatus::Valid + ); let displayname_too_long = json!({ "$type": "app.bsky.actor.profile", "displayName": "n".repeat(641) }); - assert!(matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")); + assert!( + matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") + ); let description_too_long = json!({ "$type": "app.bsky.actor.profile", "description": "d".repeat(2561) }); - assert!(matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description")); + assert!( + matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description") + ); } #[test] @@ -127,13 +173,20 @@ fn test_like_and_repost_validation() { }, "createdAt": now() }); - assert_eq!(validator.validate(&valid_like, "app.bsky.feed.like").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_like, "app.bsky.feed.like") + .unwrap(), + ValidationStatus::Valid + ); let missing_subject = json!({ "$type": "app.bsky.feed.like", "createdAt": now() }); - assert!(matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject")); + assert!( + matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject") + ); let missing_subject_uri = json!({ "$type": "app.bsky.feed.like", @@ -142,7 +195,9 @@ fn test_like_and_repost_validation() { }, "createdAt": now() }); - assert!(matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri"))); + assert!( + matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri")) + ); let invalid_subject_uri = json!({ "$type": "app.bsky.feed.like", @@ -152,7 +207,9 @@ fn test_like_and_repost_validation() { }, "createdAt": now() }); - assert!(matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))); + assert!( + matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")) + ); let valid_repost = json!({ "$type": "app.bsky.feed.repost", @@ -162,13 +219,20 @@ fn test_like_and_repost_validation() { }, "createdAt": now() }); - assert_eq!(validator.validate(&valid_repost, "app.bsky.feed.repost").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_repost, "app.bsky.feed.repost") + .unwrap(), + ValidationStatus::Valid + ); let repost_missing_subject = json!({ "$type": "app.bsky.feed.repost", "createdAt": now() }); - assert!(matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject")); + assert!( + matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject") + ); } #[test] @@ -180,34 +244,50 @@ fn test_follow_and_block_validation() { "subject": "did:plc:test12345", "createdAt": now() }); - assert_eq!(validator.validate(&valid_follow, "app.bsky.graph.follow").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_follow, "app.bsky.graph.follow") + .unwrap(), + ValidationStatus::Valid + ); let missing_follow_subject = json!({ "$type": "app.bsky.graph.follow", "createdAt": now() }); - assert!(matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject")); + assert!( + matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject") + ); let invalid_follow_subject = json!({ "$type": "app.bsky.graph.follow", "subject": "not-a-did", "createdAt": now() }); - assert!(matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")); + assert!( + matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") + ); let valid_block = json!({ "$type": "app.bsky.graph.block", "subject": "did:plc:blocked123", "createdAt": now() }); - assert_eq!(validator.validate(&valid_block, "app.bsky.graph.block").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_block, "app.bsky.graph.block") + .unwrap(), + ValidationStatus::Valid + ); let invalid_block_subject = json!({ "$type": "app.bsky.graph.block", "subject": "not-a-did", "createdAt": now() }); - assert!(matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")); + assert!( + matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") + ); } #[test] @@ -220,7 +300,12 @@ fn test_list_and_graph_records_validation() { "purpose": "app.bsky.graph.defs#modlist", "createdAt": now() }); - assert_eq!(validator.validate(&valid_list, "app.bsky.graph.list").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_list, "app.bsky.graph.list") + .unwrap(), + ValidationStatus::Valid + ); let list_name_too_long = json!({ "$type": "app.bsky.graph.list", @@ -228,7 +313,9 @@ fn test_list_and_graph_records_validation() { "purpose": "app.bsky.graph.defs#modlist", "createdAt": now() }); - assert!(matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")); + assert!( + matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") + ); let list_empty_name = json!({ "$type": "app.bsky.graph.list", @@ -236,7 +323,9 @@ fn test_list_and_graph_records_validation() { "purpose": "app.bsky.graph.defs#modlist", "createdAt": now() }); - assert!(matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")); + assert!( + matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") + ); let valid_list_item = json!({ "$type": "app.bsky.graph.listitem", @@ -244,7 +333,12 @@ fn test_list_and_graph_records_validation() { "list": "at://did:plc:owner/app.bsky.graph.list/mylist", "createdAt": now() }); - assert_eq!(validator.validate(&valid_list_item, "app.bsky.graph.listitem").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_list_item, "app.bsky.graph.listitem") + .unwrap(), + ValidationStatus::Valid + ); } #[test] @@ -257,7 +351,12 @@ fn test_misc_record_types_validation() { "displayName": "My Feed", "createdAt": now() }); - assert_eq!(validator.validate(&valid_generator, "app.bsky.feed.generator").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_generator, "app.bsky.feed.generator") + .unwrap(), + ValidationStatus::Valid + ); let generator_displayname_too_long = json!({ "$type": "app.bsky.feed.generator", @@ -265,14 +364,21 @@ fn test_misc_record_types_validation() { "displayName": "f".repeat(241), "createdAt": now() }); - assert!(matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")); + assert!( + matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") + ); let valid_threadgate = json!({ "$type": "app.bsky.feed.threadgate", "post": "at://did:plc:test/app.bsky.feed.post/123", "createdAt": now() }); - assert_eq!(validator.validate(&valid_threadgate, "app.bsky.feed.threadgate").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_threadgate, "app.bsky.feed.threadgate") + .unwrap(), + ValidationStatus::Valid + ); let valid_labeler = json!({ "$type": "app.bsky.labeler.service", @@ -281,7 +387,12 @@ fn test_misc_record_types_validation() { }, "createdAt": now() }); - assert_eq!(validator.validate(&valid_labeler, "app.bsky.labeler.service").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_labeler, "app.bsky.labeler.service") + .unwrap(), + ValidationStatus::Valid + ); } #[test] @@ -293,8 +404,16 @@ fn test_type_and_format_validation() { "$type": "com.custom.record", "data": "test" }); - assert_eq!(validator.validate(&custom_record, "com.custom.record").unwrap(), ValidationStatus::Unknown); - assert!(matches!(strict_validator.validate(&custom_record, "com.custom.record"), Err(ValidationError::UnknownType(_)))); + assert_eq!( + validator + .validate(&custom_record, "com.custom.record") + .unwrap(), + ValidationStatus::Unknown + ); + assert!(matches!( + strict_validator.validate(&custom_record, "com.custom.record"), + Err(ValidationError::UnknownType(_)) + )); let type_mismatch = json!({ "$type": "app.bsky.feed.like", @@ -309,31 +428,50 @@ fn test_type_and_format_validation() { let missing_type = json!({ "text": "Hello" }); - assert!(matches!(validator.validate(&missing_type, "app.bsky.feed.post"), Err(ValidationError::MissingType))); + assert!(matches!( + validator.validate(&missing_type, "app.bsky.feed.post"), + Err(ValidationError::MissingType) + )); let not_object = json!("just a string"); - assert!(matches!(validator.validate(¬_object, "app.bsky.feed.post"), Err(ValidationError::InvalidRecord(_)))); + assert!(matches!( + validator.validate(¬_object, "app.bsky.feed.post"), + Err(ValidationError::InvalidRecord(_)) + )); let valid_datetime = json!({ "$type": "app.bsky.feed.post", "text": "Test", "createdAt": "2024-01-15T10:30:00.000Z" }); - assert_eq!(validator.validate(&valid_datetime, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&valid_datetime, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let datetime_with_offset = json!({ "$type": "app.bsky.feed.post", "text": "Test", "createdAt": "2024-01-15T10:30:00+05:30" }); - assert_eq!(validator.validate(&datetime_with_offset, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid); + assert_eq!( + validator + .validate(&datetime_with_offset, "app.bsky.feed.post") + .unwrap(), + ValidationStatus::Valid + ); let invalid_datetime = json!({ "$type": "app.bsky.feed.post", "text": "Test", "createdAt": "2024/01/15" }); - assert!(matches!(validator.validate(&invalid_datetime, "app.bsky.feed.post"), Err(ValidationError::InvalidDatetime { .. }))); + assert!(matches!( + validator.validate(&invalid_datetime, "app.bsky.feed.post"), + Err(ValidationError::InvalidDatetime { .. }) + )); } #[test] @@ -345,7 +483,10 @@ fn test_record_key_validation() { assert!(validate_record_key("valid~key").is_ok()); assert!(validate_record_key("self").is_ok()); - assert!(matches!(validate_record_key(""), Err(ValidationError::InvalidRecord(_)))); + assert!(matches!( + validate_record_key(""), + Err(ValidationError::InvalidRecord(_)) + )); assert!(validate_record_key(".").is_err()); assert!(validate_record_key("..").is_err()); @@ -355,7 +496,10 @@ fn test_record_key_validation() { assert!(validate_record_key("invalid@key").is_err()); assert!(validate_record_key("invalid#key").is_err()); - assert!(matches!(validate_record_key(&"k".repeat(513)), Err(ValidationError::InvalidRecord(_)))); + assert!(matches!( + validate_record_key(&"k".repeat(513)), + Err(ValidationError::InvalidRecord(_)) + )); assert!(validate_record_key(&"k".repeat(512)).is_ok()); } @@ -366,7 +510,10 @@ fn test_collection_nsid_validation() { assert!(validate_collection_nsid("a.b.c").is_ok()); assert!(validate_collection_nsid("my-app.domain.record-type").is_ok()); - assert!(matches!(validate_collection_nsid(""), Err(ValidationError::InvalidRecord(_)))); + assert!(matches!( + validate_collection_nsid(""), + Err(ValidationError::InvalidRecord(_)) + )); assert!(validate_collection_nsid("a").is_err()); assert!(validate_collection_nsid("a.b").is_err()); diff --git a/tests/security_fixes.rs b/tests/security_fixes.rs index 3bcad3d..a3ff09c 100644 --- a/tests/security_fixes.rs +++ b/tests/security_fixes.rs @@ -1,7 +1,6 @@ mod common; -use tranquil_pds::image::{ImageError, ImageProcessor}; use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value}; -use tranquil_pds::oauth::templates::{error_page, login_page, success_page}; +use tranquil_pds::image::{ImageError, ImageProcessor}; #[test] fn test_header_injection_sanitization() { @@ -24,7 +23,11 @@ fn test_header_injection_sanitization() { let header_injection = "Normal Subject\r\nBcc: attacker@evil.com\r\nX-Injected: value"; let sanitized = sanitize_header_value(header_injection); assert_eq!(sanitized.split("\r\n").count(), 1); - assert!(sanitized.contains("Normal Subject") && sanitized.contains("Bcc:") && sanitized.contains("X-Injected:")); + assert!( + sanitized.contains("Normal Subject") + && sanitized.contains("Bcc:") + && sanitized.contains("X-Injected:") + ); let with_null = "client\0id"; assert!(sanitize_header_value(with_null).contains("client")); @@ -59,10 +62,21 @@ fn test_phone_number_validation() { assert!(!is_valid_phone_number("+1(234)567890")); assert!(!is_valid_phone_number("+1.234.567.890")); - for malicious in ["+123; rm -rf /", "+123 && cat /etc/passwd", "+123`id`", - "+123$(whoami)", "+123|cat /etc/shadow", "+123\n--help", - "+123\r\n--version", "+123--help"] { - assert!(!is_valid_phone_number(malicious), "Command injection '{}' should be rejected", malicious); + for malicious in [ + "+123; rm -rf /", + "+123 && cat /etc/passwd", + "+123`id`", + "+123$(whoami)", + "+123|cat /etc/shadow", + "+123\n--help", + "+123\r\n--version", + "+123--help", + ] { + assert!( + !is_valid_phone_number(malicious), + "Command injection '{}' should be rejected", + malicious + ); } } @@ -87,71 +101,6 @@ fn test_image_file_size_limits() { assert!(processor.process(&data, "image/jpeg").is_err()); } -#[test] -fn test_oauth_template_xss_protection() { - let html = login_page("", None, None, "test-uri", None, None); - assert!(!html.contains(""), "test-uri", None, None); - assert!(!html.contains(""), None); - assert!(!html.contains("", Some("")); - assert!(!html.contains("")); - assert!(!html.contains("