diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..6df2ec9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,12 @@ +[store] +dir = "target/nextest" + +[profile.default] +retries = 0 +fail-fast = true +test-threads = "num-cpus" + +[profile.ci] +retries = 2 +fail-fast = false +test-threads = "num-cpus" diff --git a/.sqlx/query-0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3.json b/.sqlx/query-0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3.json new file mode 100644 index 0000000..00081d4 --- /dev/null +++ b/.sqlx/query-0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3" +} diff --git a/.sqlx/query-03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262.json b/.sqlx/query-03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262.json new file mode 100644 index 0000000..73a46f3 --- /dev/null +++ b/.sqlx/query-03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_account_device (did, device_id, created_at, updated_at)\n VALUES ($1, $2, NOW(), NOW())\n ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262" +} diff --git a/.sqlx/query-0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52.json b/.sqlx/query-0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52.json new file mode 100644 index 0000000..21b9888 --- /dev/null +++ b/.sqlx/query-0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52" +} diff --git a/.sqlx/query-0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78.json b/.sqlx/query-0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78.json new file mode 100644 index 0000000..4cbba6b --- /dev/null +++ b/.sqlx/query-0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_device\n SET last_seen_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78" +} diff --git a/.sqlx/query-0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb.json b/.sqlx/query-0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb.json new file mode 100644 index 0000000..32251df --- /dev/null +++ b/.sqlx/query-0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) as \"count!\" FROM oauth_token WHERE did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb" +} diff --git a/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json new file mode 100644 index 0000000..fed143b --- /dev/null +++ b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT handle, email FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a" +} diff --git a/.sqlx/query-1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587.json b/.sqlx/query-1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587.json new file mode 100644 index 0000000..8aac29e --- /dev/null +++ b/.sqlx/query-1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT data FROM oauth_authorized_client\n WHERE did = $1 AND client_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "data", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587" +} diff --git a/.sqlx/query-235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766.json b/.sqlx/query-235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766.json new file mode 100644 index 0000000..65f4a64 --- /dev/null +++ b/.sqlx/query-235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_device WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766" +} diff --git a/.sqlx/query-26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533.json b/.sqlx/query-26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533.json new file mode 100644 index 0000000..064d4d7 --- /dev/null +++ b/.sqlx/query-26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, code = $4\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533" +} diff --git a/.sqlx/query-2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc.json b/.sqlx/query-2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc.json new file mode 100644 index 0000000..260389c --- /dev/null +++ b/.sqlx/query-2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_used_refresh_token (refresh_token, token_id)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc" +} diff --git a/.sqlx/query-9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49.json b/.sqlx/query-31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09.json similarity index 53% rename from .sqlx/query-9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49.json rename to .sqlx/query-31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09.json index 4875119..99caae1 100644 --- a/.sqlx/query-9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49.json +++ b/.sqlx/query-31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM sessions WHERE did = $1", + "query": "DELETE FROM session_tokens WHERE did = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49" + "hash": "31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09" } diff --git a/.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json b/.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json new file mode 100644 index 0000000..40a8cfc --- /dev/null +++ b/.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817" +} diff --git a/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json b/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json new file mode 100644 index 0000000..5bf483c --- /dev/null +++ b/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd" +} diff --git a/.sqlx/query-47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf.json b/.sqlx/query-47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf.json deleted file mode 100644 index d1f4af3..0000000 --- a/.sqlx/query-47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 2, - "name": "handle", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf" -} diff --git a/.sqlx/query-48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416.json b/.sqlx/query-48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416.json deleted file mode 100644 index 52b2f91..0000000 --- a/.sqlx/query-48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416" -} diff --git a/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json b/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json new file mode 100644 index 0000000..fbbcdd7 --- /dev/null +++ b/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234" +} diff --git a/.sqlx/query-52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550.json b/.sqlx/query-52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550.json new file mode 100644 index 0000000..94a3977 --- /dev/null +++ b/.sqlx/query-52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_authorization_request\n (id, did, device_id, client_id, client_auth, parameters, expires_at, code)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Jsonb", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550" +} diff --git a/.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.json b/.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.json new file mode 100644 index 0000000..67fb8f0 --- /dev/null +++ b/.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "token_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "details", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "current_refresh_token", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "scope", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765" +} diff --git a/.sqlx/query-55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275.json b/.sqlx/query-55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275.json deleted file mode 100644 index 8284690..0000000 --- a/.sqlx/query-55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, u.id as user_id, u.email, u.handle, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "handle", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "key_bytes", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275" -} diff --git a/.sqlx/query-2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601.json b/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json similarity index 69% rename from .sqlx/query-2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601.json rename to .sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json index de71d5e..ea16568 100644 --- a/.sqlx/query-2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601.json +++ b/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", + "query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", "describe": { "columns": [ { @@ -27,6 +27,11 @@ "ordinal": 4, "name": "key_bytes", "type_info": "Bytea" + }, + { + "ordinal": 5, + "name": "encryption_version", + "type_info": "Int4" } ], "parameters": { @@ -39,8 +44,9 @@ false, false, false, - false + false, + true ] }, - "hash": "2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601" + "hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb" } diff --git a/.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json b/.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json deleted file mode 100644 index 55fe659..0000000 --- a/.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 2, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "handle", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175" -} diff --git a/.sqlx/query-6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0.json b/.sqlx/query-6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0.json new file mode 100644 index 0000000..32de36c --- /dev/null +++ b/.sqlx/query-6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_token\n (did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Timestamptz", + "Timestamptz", + "Text", + "Jsonb", + "Text", + "Jsonb", + "Jsonb", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0" +} diff --git a/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json b/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json new file mode 100644 index 0000000..344cdc0 --- /dev/null +++ b/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "takedown_ref", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" +} diff --git a/.sqlx/query-73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7.json b/.sqlx/query-73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7.json new file mode 100644 index 0000000..1a5d68e --- /dev/null +++ b/.sqlx/query-73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7" +} diff --git a/.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json b/.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json new file mode 100644 index 0000000..474075b --- /dev/null +++ b/.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd" +} diff --git a/.sqlx/query-7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974.json b/.sqlx/query-7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974.json new file mode 100644 index 0000000..06a8f96 --- /dev/null +++ b/.sqlx/query-7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_dpop_jti (jti)\n VALUES ($1)\n ON CONFLICT (jti) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974" +} diff --git a/.sqlx/query-f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14.json b/.sqlx/query-7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92.json similarity index 52% rename from .sqlx/query-f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14.json rename to .sqlx/query-7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92.json index c650392..e5184b9 100644 --- a/.sqlx/query-f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14.json +++ b/.sqlx/query-7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92.json @@ -1,17 +1,17 @@ { "db_name": "PostgreSQL", - "query": "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", + "query": "SELECT k.key_bytes, k.encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "did", - "type_info": "Text" + "name": "key_bytes", + "type_info": "Bytea" }, { "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" + "name": "encryption_version", + "type_info": "Int4" } ], "parameters": { @@ -21,8 +21,8 @@ }, "nullable": [ false, - false + true ] }, - "hash": "f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14" + "hash": "7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92" } diff --git a/.sqlx/query-7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9.json b/.sqlx/query-7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9.json deleted file mode 100644 index baefa30..0000000 --- a/.sqlx/query-7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email,\n u.email_confirmation_code, u.email_confirmation_code_expires_at,\n u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 2, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "current_email", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "email_confirmation_code", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "email_confirmation_code_expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "email_pending_verification", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true - ] - }, - "hash": "7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9" -} diff --git a/.sqlx/query-52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89.json b/.sqlx/query-847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6.json similarity index 52% rename from .sqlx/query-52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89.json rename to .sqlx/query-847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6.json index 1038904..0c0e68f 100644 --- a/.sqlx/query-52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89.json +++ b/.sqlx/query-847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM sessions WHERE access_jwt = $1", + "query": "DELETE FROM session_tokens WHERE access_jti = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89" + "hash": "847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6" } diff --git a/.sqlx/query-09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e.json b/.sqlx/query-91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43.json similarity index 51% rename from .sqlx/query-09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e.json rename to .sqlx/query-91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43.json index 6ea6e51..7a20bbb 100644 --- a/.sqlx/query-09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e.json +++ b/.sqlx/query-91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43.json @@ -1,27 +1,27 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.handle, u.did, u.email, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", + "query": "\n SELECT did, password_hash, deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "handle", - "type_info": "Text" - }, - { - "ordinal": 1, "name": "did", "type_info": "Text" }, { - "ordinal": 2, - "name": "email", + "ordinal": 1, + "name": "password_hash", "type_info": "Text" }, + { + "ordinal": 2, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, { "ordinal": 3, - "name": "key_bytes", - "type_info": "Bytea" + "name": "takedown_ref", + "type_info": "Text" } ], "parameters": { @@ -32,9 +32,9 @@ "nullable": [ false, false, - false, - false + true, + true ] }, - "hash": "09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e" + "hash": "91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43" } diff --git a/.sqlx/query-93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043.json b/.sqlx/query-93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043.json new file mode 100644 index 0000000..3030a1b --- /dev/null +++ b/.sqlx/query-93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT current_refresh_token FROM oauth_token WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "current_refresh_token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true + ] + }, + "hash": "93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043" +} diff --git a/.sqlx/query-a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83.json b/.sqlx/query-a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83.json deleted file mode 100644 index fe9f400..0000000 --- a/.sqlx/query-a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83" -} diff --git a/.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json similarity index 56% rename from .sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json rename to .sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json index b186596..ea0c2f1 100644 --- a/.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json +++ b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json @@ -1,35 +1,30 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", + "query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 2, - "name": "user_id", + "name": "id", "type_info": "Uuid" }, { - "ordinal": 3, + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, "name": "email_confirmation_code", "type_info": "Text" }, { - "ordinal": 4, + "ordinal": 3, "name": "email_confirmation_code_expires_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 4, "name": "email_pending_verification", "type_info": "Text" } @@ -40,7 +35,6 @@ ] }, "nullable": [ - false, false, false, true, @@ -48,5 +42,5 @@ true ] }, - "hash": "3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d" + "hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a" } diff --git a/.sqlx/query-b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268.json b/.sqlx/query-b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268.json new file mode 100644 index 0000000..f3ce551 --- /dev/null +++ b/.sqlx/query-b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268" +} diff --git a/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json b/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json new file mode 100644 index 0000000..5c9bf0b --- /dev/null +++ b/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email_confirmation_code", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_confirmation_code_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "email_pending_verification", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5" +} diff --git a/.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.json b/.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.json new file mode 100644 index 0000000..af03b5d --- /dev/null +++ b/.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE token_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "token_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "details", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "current_refresh_token", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "scope", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8" +} diff --git a/.sqlx/query-b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230.json b/.sqlx/query-b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230.json new file mode 100644 index 0000000..5450bf5 --- /dev/null +++ b/.sqlx/query-b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_authorization_request\n WHERE expires_at < NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230" +} diff --git a/.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json b/.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json new file mode 100644 index 0000000..4b21415 --- /dev/null +++ b/.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284" +} diff --git a/.sqlx/query-bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5.json b/.sqlx/query-bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5.json new file mode 100644 index 0000000..ccd43f3 --- /dev/null +++ b/.sqlx/query-bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_token WHERE token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5" +} diff --git a/.sqlx/query-bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95.json b/.sqlx/query-bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95.json new file mode 100644 index 0000000..d377c3e --- /dev/null +++ b/.sqlx/query-bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE current_refresh_token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "details", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "current_refresh_token", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "scope", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95" +} diff --git a/.sqlx/query-bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3.json b/.sqlx/query-bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3.json new file mode 100644 index 0000000..d41ba70 --- /dev/null +++ b/.sqlx/query-bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3" +} diff --git a/.sqlx/query-c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3.json b/.sqlx/query-c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3.json deleted file mode 100644 index 1ee8429..0000000 --- a/.sqlx/query-c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Bytea" - ] - }, - "nullable": [] - }, - "hash": "c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3" -} diff --git a/.sqlx/query-c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5.json b/.sqlx/query-c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5.json new file mode 100644 index 0000000..013e97d --- /dev/null +++ b/.sqlx/query-c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_token WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5" +} diff --git a/.sqlx/query-c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac.json b/.sqlx/query-c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac.json deleted file mode 100644 index 53c30a2..0000000 --- a/.sqlx/query-c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.refresh_jwt = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac" -} diff --git a/.sqlx/query-c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e.json b/.sqlx/query-c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e.json new file mode 100644 index 0000000..59c8633 --- /dev/null +++ b/.sqlx/query-c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT session_id, user_agent, ip_address, last_seen_at\n FROM oauth_device\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "session_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "ip_address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_seen_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false, + false + ] + }, + "hash": "c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e" +} diff --git a/.sqlx/query-cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b.json b/.sqlx/query-cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b.json new file mode 100644 index 0000000..d1292c6 --- /dev/null +++ b/.sqlx/query-cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b" +} diff --git a/.sqlx/query-cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1.json b/.sqlx/query-cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1.json new file mode 100644 index 0000000..ab31439 --- /dev/null +++ b/.sqlx/query-cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, handle FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1" +} diff --git a/.sqlx/query-cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789.json b/.sqlx/query-cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789.json new file mode 100644 index 0000000..c2595b3 --- /dev/null +++ b/.sqlx/query-cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data)\n VALUES ($1, $2, NOW(), NOW(), $3)\n ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789" +} diff --git a/.sqlx/query-ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd.json b/.sqlx/query-ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd.json deleted file mode 100644 index 147307b..0000000 --- a/.sqlx/query-ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT key_bytes FROM user_keys WHERE user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "key_bytes", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd" -} diff --git a/.sqlx/query-cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f.json b/.sqlx/query-cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f.json new file mode 100644 index 0000000..7586fd3 --- /dev/null +++ b/.sqlx/query-cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM session_tokens WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f" +} diff --git a/.sqlx/query-d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d.json b/.sqlx/query-d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d.json deleted file mode 100644 index b67bd9e..0000000 --- a/.sqlx/query-d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.did, k.key_bytes, u.id as user_id\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 2, - "name": "user_id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d" -} diff --git a/.sqlx/query-d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367.json b/.sqlx/query-d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367.json new file mode 100644 index 0000000..7ca4284 --- /dev/null +++ b/.sqlx/query-d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367" +} diff --git a/.sqlx/query-d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3.json b/.sqlx/query-d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3.json new file mode 100644 index 0000000..0ed7bd5 --- /dev/null +++ b/.sqlx/query-d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code\n FROM oauth_authorization_request\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + true + ] + }, + "hash": "d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3" +} diff --git a/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json b/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json new file mode 100644 index 0000000..004e5c0 --- /dev/null +++ b/.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "one", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7" +} diff --git a/.sqlx/query-db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64.json b/.sqlx/query-db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64.json deleted file mode 100644 index faaae03..0000000 --- a/.sqlx/query-db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64" -} diff --git a/.sqlx/query-df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4.json b/.sqlx/query-df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4.json new file mode 100644 index 0000000..bb9eccb --- /dev/null +++ b/.sqlx/query-df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_authorization_request\n WHERE code = $1\n RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "device_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_auth", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + true + ] + }, + "hash": "df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4" +} diff --git a/.sqlx/query-ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee.json b/.sqlx/query-ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee.json deleted file mode 100644 index 7592257..0000000 --- a/.sqlx/query-ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "key_bytes", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee" -} diff --git a/.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json b/.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json new file mode 100644 index 0000000..55a8a9e --- /dev/null +++ b/.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n WHERE t.token_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "takedown_ref", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07" +} diff --git a/.sqlx/query-f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e.json b/.sqlx/query-f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e.json new file mode 100644 index 0000000..98b4c54 --- /dev/null +++ b/.sqlx/query-f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_dpop_jti\n WHERE created_at < NOW() - INTERVAL '1 second' * $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Float8" + ] + }, + "nullable": [] + }, + "hash": "f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e" +} diff --git a/.sqlx/query-f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce.json b/.sqlx/query-f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce.json new file mode 100644 index 0000000..ecdacf7 --- /dev/null +++ b/.sqlx/query-f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_authorization_request WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce" +} diff --git a/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json b/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json new file mode 100644 index 0000000..c39151a --- /dev/null +++ b/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "session_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3" +} diff --git a/.sqlx/query-fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535.json b/.sqlx/query-fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535.json deleted file mode 100644 index 92f0634..0000000 --- a/.sqlx/query-fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535" -} diff --git a/Cargo.lock b/Cargo.lock index 6cf9e05..7d69fc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -865,6 +900,7 @@ dependencies = [ name = "bspds" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "aws-config", @@ -877,7 +913,10 @@ dependencies = [ "cid", "ctor", "dotenvy", + "ed25519-dalek", "futures", + "hkdf", + "hmac", "iroh-car", "jacquard", "jacquard-axum", @@ -886,6 +925,8 @@ dependencies = [ "k256", "multibase", "multihash", + "p256 0.13.2", + "p384", "rand 0.8.5", "reqwest", "serde", @@ -894,6 +935,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "subtle", "testcontainers", "testcontainers-modules", "thiserror 2.0.17", @@ -901,6 +943,7 @@ dependencies = [ "tokio-tungstenite", "tracing", "tracing-subscriber", + "urlencoding", "uuid", "wiremock", ] @@ -1303,6 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1322,6 +1366,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -2071,6 +2124,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -3610,6 +3673,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -3905,6 +3974,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -5855,6 +5936,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsigned-varint" version = "0.7.2" diff --git a/Cargo.toml b/Cargo.toml index a395b66..ed22611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ chrono = { version = "0.4.42", features = ["serde"] } cid = "0.11.1" dotenvy = "0.15.7" futures = "0.3.30" +hkdf = "0.12" +hmac = "0.12" +aes-gcm = "0.10" jacquard = { version = "0.9.3", default-features = false, features = ["api", "api_bluesky", "api_full", "derive", "dns"] } jacquard-axum = "0.9.2" jacquard-repo = "0.9.2" @@ -30,12 +33,17 @@ serde_bytes = "0.11.14" serde_ipld_dagcbor = "0.6.4" serde_json = "1.0.145" sha2 = "0.10.9" +subtle = "2.5" +p256 = { version = "0.13", features = ["ecdsa"] } +p384 = { version = "0.13", features = ["ecdsa"] } +ed25519-dalek = { version = "2.1", features = ["pkcs8"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] } thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "time", "signal", "process"] } tracing = "0.1.43" tracing-subscriber = "0.3.22" tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } +urlencoding = "2.1" uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } [dev-dependencies] @@ -44,3 +52,5 @@ iroh-car = "0.5.1" testcontainers = "0.26.0" testcontainers-modules = { version = "0.14.0", features = ["postgres"] } wiremock = "0.6.5" + +# urlencoding is also in dependencies, but tests use it directly diff --git a/TODO.md b/TODO.md index 1ecb55b..5c400ae 100644 --- a/TODO.md +++ b/TODO.md @@ -110,23 +110,53 @@ Lewis' corrected big boy todofile ## Temp Namespace (`com.atproto.temp`) - [ ] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). -## OAuth 2.0 Support -The reference PDS implements full OAuth 2.0 provider functionality for native app authentication. -- [ ] OAuth Provider Core - - [ ] Implement `/.well-known/oauth-protected-resource` metadata endpoint. - - [ ] Implement `/.well-known/oauth-authorization-server` metadata endpoint. - - [ ] Implement `/oauth/authorize` authorization endpoint. - - [ ] Implement `/oauth/par` Pushed Authorization Request endpoint. - - [ ] Implement `/oauth/token` token endpoint. - - [ ] Implement `/oauth/jwks` JSON Web Key Set endpoint. -- [ ] OAuth Database Tables - - [ ] Device table for tracking authorized devices. - - [ ] Authorization request table. - - [ ] Authorized client table. - - [ ] Token table for OAuth tokens. - - [ ] Used refresh token table. -- [ ] DPoP (Demonstrating Proof-of-Possession) support. -- [ ] Client metadata fetching and validation. +## OAuth 2.1 Support +Full OAuth 2.1 provider for ATProto native app authentication. +- [x] OAuth Provider Core + - [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint. + - [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint. + - [x] Implement `/oauth/authorize` authorization endpoint (headless JSON mode). + - [x] Implement `/oauth/par` Pushed Authorization Request endpoint. + - [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants). + - [x] Implement `/oauth/jwks` JSON Web Key Set endpoint. + - [x] Implement `/oauth/revoke` token revocation endpoint. + - [x] Implement `/oauth/introspect` token introspection endpoint. +- [x] OAuth Database Tables + - [x] Device table for tracking authorized devices. + - [x] Authorization request table. + - [x] Authorized client table. + - [x] Token table for OAuth tokens. + - [x] Used refresh token table (replay protection). + - [x] DPoP JTI tracking table. +- [x] DPoP (Demonstrating Proof-of-Possession) support. +- [x] Client metadata fetching and validation. +- [x] PKCE (S256) enforcement. +- [x] OAuth token verification extractor for protected resources. +- [ ] Authorization UI templates (currently headless-only, returns JSON for programmatic flows). +- [ ] Implement `private_key_jwt` signature verification (currently rejects with clear error). + +## OAuth Security Notes + +I've tried to ensure that this codebase is not vulnerable to the following: + +- Constant-time comparison for signature verification (prevents timing attacks) +- HMAC-SHA256 for access token signing with configurable secret +- Production secrets require 32+ character minimum +- DPoP JTI replay protection via database +- DPoP nonce validation with HMAC-based timestamps (5 min validity) +- Refresh token rotation with reuse detection (revokes token family on reuse) +- PKCE S256 enforced (plain not allowed) +- Authorization code single-use enforcement +- URL encoding for redirect parameters (prevents injection) +- All database queries use parameterized statements (no SQL injection) +- Deactivated/taken-down accounts blocked from OAuth authorization +- Client ID validation on token exchange (defense-in-depth against cross-client attacks) + +### Auth Notes +- Algorithm choice: Using ES256K (secp256k1 ECDSA) with per-user keys. Ref PDS uses HS256 (HMAC) with single server key. Our approach provides better key isolation but differs from reference implementation. + - [ ] Support the ref PDS HS256 system too. +- Token storage: Now storing only token JTIs in session_tokens table (defense in depth against DB breaches). Refresh token family tracking enables detection of token reuse attacks. +- Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from MASTER_KEY environment variable. Migration-safe: supports both encrypted (version 1) and plaintext (version 0) keys. ## PDS-Level App Endpoints These endpoints need to be implemented at the PDS level (not just proxied to appview). diff --git a/justfile b/justfile index 324d780..05e253f 100644 --- a/justfile +++ b/justfile @@ -27,32 +27,27 @@ fmt-check: lint: fmt-check clippy -test: - cargo test +# Run tests (auto-starts and auto-cleans containers) +test *args: + ./scripts/run-tests.sh {{args}} -test-verbose: - cargo test -- --nocapture +# Run a specific test file +test-file file: + ./scripts/run-tests.sh --test {{file}} -test-repo: - cargo test --test repo +# Run tests with testcontainers (slower, no shared infra) +test-standalone: + BSPDS_ALLOW_INSECURE_SECRETS=1 cargo test -test-lifecycle: - cargo test --test lifecycle +# Manually manage test infrastructure (for debugging) +test-infra-start: + ./scripts/test-infra.sh start -test-proxy: - cargo test --test proxy +test-infra-stop: + ./scripts/test-infra.sh stop -test-sync: - cargo test --test sync - -test-server: - cargo test --test server - -test-identity: - cargo test --test identity - -test-auth: - cargo test --test auth +test-infra-status: + ./scripts/test-infra.sh status clean: cargo clean diff --git a/migrations/202512211400_initial_schema.sql b/migrations/202512211400_initial_schema.sql index 9d3cb69..093e4a7 100644 --- a/migrations/202512211400_initial_schema.sql +++ b/migrations/202512211400_initial_schema.sql @@ -18,15 +18,12 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - -- status & moderation deactivated_at TIMESTAMPTZ, invites_disabled BOOLEAN DEFAULT FALSE, takedown_ref TEXT, - -- notifs preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', - -- auth & verification password_reset_code TEXT, password_reset_code_expires_at TIMESTAMPTZ, @@ -54,11 +51,12 @@ CREATE TABLE IF NOT EXISTS invite_code_uses ( UNIQUE(code, used_by_user) ); --- TODO: encrypt at rest! CREATE TABLE IF NOT EXISTS user_keys ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, key_bytes BYTEA NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + encrypted_at TIMESTAMPTZ, + encryption_version INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS repos ( @@ -68,14 +66,12 @@ CREATE TABLE IF NOT EXISTS repos ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- content addressable storage CREATE TABLE IF NOT EXISTS blocks ( cid BYTEA PRIMARY KEY, data BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- denormalized index for fast queries CREATE TABLE IF NOT EXISTS records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE, @@ -97,13 +93,6 @@ CREATE TABLE IF NOT EXISTS blobs ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS sessions ( - access_jwt TEXT PRIMARY KEY, - refresh_jwt TEXT NOT NULL UNIQUE, - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - CREATE TABLE IF NOT EXISTS app_passwords ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -114,7 +103,6 @@ CREATE TABLE IF NOT EXISTS app_passwords ( UNIQUE(user_id, name) ); --- naughty list CREATE TABLE reports ( id BIGINT PRIMARY KEY, reason_type TEXT NOT NULL, @@ -155,3 +143,125 @@ CREATE INDEX idx_notification_queue_status_scheduled WHERE status = 'pending'; CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); + +CREATE TABLE IF NOT EXISTS reserved_signing_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT, + public_key_did_key TEXT NOT NULL, + private_key_bytes BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours', + used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL; + +CREATE TABLE repo_seq ( + seq BIGSERIAL PRIMARY KEY, + did TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_type TEXT NOT NULL, + commit_cid TEXT, + prev_cid TEXT, + ops JSONB, + blobs TEXT[], + blocks_cids TEXT[] +); + +CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); +CREATE INDEX idx_repo_seq_did ON repo_seq(did); + +CREATE TABLE IF NOT EXISTS session_tokens ( + id SERIAL PRIMARY KEY, + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + access_jti TEXT NOT NULL UNIQUE, + refresh_jti TEXT NOT NULL UNIQUE, + access_expires_at TIMESTAMPTZ NOT NULL, + refresh_expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_session_tokens_did ON session_tokens(did); +CREATE INDEX idx_session_tokens_access_jti ON session_tokens(access_jti); +CREATE INDEX idx_session_tokens_refresh_jti ON session_tokens(refresh_jti); + +CREATE TABLE IF NOT EXISTS used_refresh_tokens ( + refresh_jti TEXT PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES session_tokens(id) ON DELETE CASCADE, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_used_refresh_tokens_session_id ON used_refresh_tokens(session_id); + +CREATE TABLE IF NOT EXISTS oauth_device ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + user_agent TEXT, + ip_address TEXT NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS oauth_authorization_request ( + id TEXT PRIMARY KEY, + did TEXT REFERENCES users(did) ON DELETE CASCADE, + device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL, + client_id TEXT NOT NULL, + client_auth JSONB, + parameters JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + code TEXT UNIQUE +); + +CREATE INDEX idx_oauth_auth_request_expires ON oauth_authorization_request(expires_at); +CREATE INDEX idx_oauth_auth_request_code ON oauth_authorization_request(code) WHERE code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS oauth_token ( + id SERIAL PRIMARY KEY, + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + token_id TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + client_id TEXT NOT NULL, + client_auth JSONB NOT NULL, + device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL, + parameters JSONB NOT NULL, + details JSONB, + code TEXT UNIQUE, + current_refresh_token TEXT UNIQUE, + scope TEXT +); + +CREATE INDEX idx_oauth_token_did ON oauth_token(did); +CREATE INDEX idx_oauth_token_code ON oauth_token(code) WHERE code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS oauth_account_device ( + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES oauth_device(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (did, device_id) +); + +CREATE TABLE IF NOT EXISTS oauth_authorized_client ( + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + client_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + data JSONB NOT NULL, + PRIMARY KEY (did, client_id) +); + +CREATE TABLE IF NOT EXISTS oauth_used_refresh_token ( + refresh_token TEXT PRIMARY KEY, + token_id INTEGER NOT NULL REFERENCES oauth_token(id) ON DELETE CASCADE +); + +CREATE TABLE oauth_dpop_jti ( + jti TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at); diff --git a/migrations/202512211401_reserved_signing_keys.sql b/migrations/202512211401_reserved_signing_keys.sql deleted file mode 100644 index dbda92a..0000000 --- a/migrations/202512211401_reserved_signing_keys.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS reserved_signing_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - did TEXT, - public_key_did_key TEXT NOT NULL, - private_key_bytes BYTEA NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours', - used_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL; diff --git a/migrations/202512211402_repo_sequencer.sql b/migrations/202512211402_repo_sequencer.sql deleted file mode 100644 index 1cf3185..0000000 --- a/migrations/202512211402_repo_sequencer.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE repo_seq ( - seq BIGSERIAL PRIMARY KEY, - did TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - event_type TEXT NOT NULL, - commit_cid TEXT, - prev_cid TEXT, - ops JSONB, - blobs TEXT[] -); - -CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); -CREATE INDEX idx_repo_seq_did ON repo_seq(did); diff --git a/migrations/202512211403_add_blocks_cids_to_repo_seq.sql b/migrations/202512211403_add_blocks_cids_to_repo_seq.sql deleted file mode 100644 index 52b24d9..0000000 --- a/migrations/202512211403_add_blocks_cids_to_repo_seq.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE repo_seq ADD COLUMN blocks_cids TEXT[]; - diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..1b4ce95 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh" + +cleanup() { + echo "" + echo "Cleaning up test infrastructure..." + "$INFRA_SCRIPT" stop +} + +trap cleanup EXIT + +"$INFRA_SCRIPT" start + +source "${TMPDIR:-/tmp}/bspds_test_infra.env" + +echo "" +echo "Running database migrations..." +sqlx database create 2>/dev/null || true +sqlx migrate run --source "$PROJECT_DIR/migrations" + +echo "" +echo "Running tests..." +echo "" + +cargo nextest run "$@" diff --git a/scripts/test-infra.sh b/scripts/test-infra.sh new file mode 100755 index 0000000..9f481f0 --- /dev/null +++ b/scripts/test-infra.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +INFRA_FILE="${TMPDIR:-/tmp}/bspds_test_infra.env" +CONTAINER_PREFIX="bspds-test" + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +if command_exists podman; then + CONTAINER_CMD="podman" + if [[ -z "${DOCKER_HOST:-}" ]]; then + RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + PODMAN_SOCK="$RUNTIME_DIR/podman/podman.sock" + if [[ -S "$PODMAN_SOCK" ]]; then + export DOCKER_HOST="unix://$PODMAN_SOCK" + fi + fi +elif command_exists docker; then + CONTAINER_CMD="docker" +else + echo "Error: Neither podman nor docker found" >&2 + exit 1 +fi + +start_infra() { + echo "Starting test infrastructure..." + + if [[ -f "$INFRA_FILE" ]]; then + source "$INFRA_FILE" + if $CONTAINER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_PREFIX}-postgres$"; then + echo "Infrastructure already running (found $INFRA_FILE)" + cat "$INFRA_FILE" + return 0 + fi + echo "Stale infra file found, cleaning up..." + rm -f "$INFRA_FILE" + fi + + $CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true + + echo "Starting PostgreSQL..." + $CONTAINER_CMD run -d \ + --name "${CONTAINER_PREFIX}-postgres" \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_DB=postgres \ + -P \ + --label bspds_test=true \ + postgres:18-alpine >/dev/null + + echo "Starting MinIO..." + $CONTAINER_CMD run -d \ + --name "${CONTAINER_PREFIX}-minio" \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + -P \ + --label bspds_test=true \ + minio/minio:latest server /data >/dev/null + + echo "Waiting for services to be ready..." + sleep 2 + + PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2) + MINIO_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-minio" 9000 | head -1 | cut -d: -f2) + + for i in {1..30}; do + if $CONTAINER_CMD exec "${CONTAINER_PREFIX}-postgres" pg_isready -U postgres >/dev/null 2>&1; then + break + fi + echo "Waiting for PostgreSQL... ($i/30)" + sleep 1 + done + + for i in {1..30}; do + if curl -s "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null 2>&1; then + break + fi + echo "Waiting for MinIO... ($i/30)" + sleep 1 + done + + echo "Creating MinIO bucket..." + $CONTAINER_CMD run --rm --network host \ + -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ + minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true + + cat > "$INFRA_FILE" << EOF +export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" +export TEST_DB_PORT="${PG_PORT}" +export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" +export S3_BUCKET="test-bucket" +export AWS_ACCESS_KEY_ID="minioadmin" +export AWS_SECRET_ACCESS_KEY="minioadmin" +export AWS_REGION="us-east-1" +export BSPDS_TEST_INFRA_READY="1" +export BSPDS_ALLOW_INSECURE_SECRETS="1" +EOF + + echo "" + echo "Infrastructure ready!" + echo "Config written to: $INFRA_FILE" + echo "" + cat "$INFRA_FILE" +} + +stop_infra() { + echo "Stopping test infrastructure..." + $CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true + rm -f "$INFRA_FILE" + echo "Infrastructure stopped." +} + +status_infra() { + echo "Test Infrastructure Status:" + echo "============================" + + if [[ -f "$INFRA_FILE" ]]; then + echo "Config file: $INFRA_FILE" + source "$INFRA_FILE" + echo "Database URL: $DATABASE_URL" + echo "S3 Endpoint: $S3_ENDPOINT" + else + echo "Config file: NOT FOUND" + fi + + echo "" + echo "Containers:" + $CONTAINER_CMD ps -a --filter "label=bspds_test=true" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (none)" +} + +case "${1:-}" in + start) + start_infra + ;; + stop) + stop_infra + ;; + restart) + stop_infra + start_infra + ;; + status) + status_infra + ;; + env) + if [[ -f "$INFRA_FILE" ]]; then + cat "$INFRA_FILE" + else + echo "Infrastructure not running. Run: $0 start" >&2 + exit 1 + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status|env}" + echo "" + echo "Commands:" + echo " start - Start test infrastructure (Postgres, MinIO)" + echo " stop - Stop and remove test containers" + echo " restart - Stop then start infrastructure" + echo " status - Show infrastructure status" + echo " env - Output environment variables for sourcing" + exit 1 + ;; +esac diff --git a/src/api/admin/account.rs b/src/api/admin/account.rs index b10f426..e04f673 100644 --- a/src/api/admin/account.rs +++ b/src/api/admin/account.rs @@ -214,7 +214,7 @@ pub async fn delete_account( } }; - let _ = sqlx::query!("DELETE FROM sessions WHERE did = $1", did) + let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) .execute(&state.db) .await; diff --git a/src/api/feed/timeline.rs b/src/api/feed/timeline.rs index f72b5de..078aed3 100644 --- a/src/api/feed/timeline.rs +++ b/src/api/feed/timeline.rs @@ -44,31 +44,22 @@ pub async fn get_timeline( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", - token - ) - .fetch_optional(&state.db) - .await - .unwrap_or(None); - - let (did, key_bytes) = match session { - Some(row) => (row.did, row.key_bytes), + let token = match crate::auth::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(); + } + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed"})), @@ -77,15 +68,7 @@ pub async fn get_timeline( } }; - if crate::auth::verify_token(&token, &key_bytes).is_err() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - - let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) + let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did) .fetch_optional(&state.db) .await; diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index a0c090a..af8ac57 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -228,10 +228,23 @@ pub async fn create_account( (secret_key.to_bytes().to_vec(), None) }; + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { + Ok(enc) => enc, + Err(e) => { + error!("Error encrypting user key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let key_insert = sqlx::query!( - "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", user_id, - &secret_key_bytes[..] + &encrypted_key_bytes[..], + crate::config::ENCRYPTION_VERSION ) .execute(&mut *tx) .await; @@ -345,7 +358,7 @@ pub async fn create_account( } } - let access_jwt = crate::auth::create_access_token(&did, &secret_key_bytes[..]).map_err(|e| { + let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { error!("Error creating access token: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -353,12 +366,12 @@ pub async fn create_account( ) .into_response() }); - let access_jwt = match access_jwt { - Ok(t) => t, + let access_meta = match access_meta { + Ok(m) => m, Err(r) => return r, }; - let refresh_jwt = crate::auth::create_refresh_token(&did, &secret_key_bytes[..]).map_err(|e| { + let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { error!("Error creating refresh token: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -366,13 +379,20 @@ pub async fn create_account( ) .into_response() }); - let refresh_jwt = match refresh_jwt { - Ok(t) => t, + let refresh_meta = match refresh_meta { + Ok(m) => m, Err(r) => return r, }; let session_insert = - sqlx::query!("INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", access_jwt, refresh_jwt, did) + sqlx::query!( + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + did, + access_meta.jti, + refresh_meta.jti, + access_meta.expires_at, + refresh_meta.expires_at + ) .execute(&mut *tx) .await; @@ -410,8 +430,8 @@ pub async fn create_account( ( StatusCode::OK, Json(CreateAccountOutput { - access_jwt, - refresh_jwt, + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, handle: input.handle, did, }), diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 921d787..15f5d6a 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -121,12 +121,23 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path = match key_row { - Ok(Some(row)) => row.key_bytes, + Ok(Some(row)) => { + match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { + Ok(k) => k, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -270,45 +281,37 @@ pub async fn get_recommended_did_credentials( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.handle - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, handle) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.handle), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(ref user) => user.did.clone(), Err(e) => { - error!("DB error in get_recommended_did_credentials: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": 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", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -316,14 +319,18 @@ pub async fn get_recommended_did_credentials( .into_response(); } }; + let handle = user.handle; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let key_bytes = match auth_result.ok().and_then(|u| u.key_bytes) { + Some(kb) => kb, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot get DID credentials"})), + ) + .into_response(); + } + }; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_endpoint = format!("https://{}", hostname); @@ -376,45 +383,37 @@ pub async fn update_handle( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in update_handle: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -423,14 +422,6 @@ pub async fn update_handle( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let new_handle = input.handle.trim(); if new_handle.is_empty() { return ( diff --git a/src/api/moderation/mod.rs b/src/api/moderation/mod.rs index 3ce16cd..ce617b5 100644 --- a/src/api/moderation/mod.rs +++ b/src/api/moderation/mod.rs @@ -33,60 +33,30 @@ pub async fn create_report( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes) = match session { - Ok(Some(row)) => (row.did, row.key_bytes), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in create_report: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; let valid_reason_types = [ "com.atproto.moderation.defs#reasonSpam", diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 303f39b..397871f 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -43,17 +43,18 @@ pub async fn proxy_handler( let mut auth_header_val = headers.get("Authorization").map(|h| h.clone()); if let Some(aud) = &proxy_header { - if let Some(auth_val) = &auth_header_val { - if let Ok(token) = auth_val.to_str() { - let token = token.replace("Bearer ", ""); - if let Ok(did) = crate::auth::get_did_from_token(&token) { - let key_row = sqlx::query!("SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", did) - .fetch_optional(&state.db) - .await; + if let Some(token) = crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()) + ) { + if let Ok(did) = crate::auth::get_did_from_token(&token) { + let key_row = sqlx::query!("SELECT k.key_bytes, k.encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", did) + .fetch_optional(&state.db) + .await; - if let Ok(Some(row)) = key_row { + if let Ok(Some(row)) = key_row { + if let Ok(decrypted_key) = crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { if let Ok(new_token) = - crate::auth::create_service_token(&did, aud, &method, &row.key_bytes) + crate::auth::create_service_token(&did, aud, &method, &decrypted_key) { if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index a685013..df8f581 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -20,31 +20,22 @@ pub async fn upload_blob( headers: axum::http::HeaderMap, body: Bytes, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", - token - ) - .fetch_optional(&state.db) - .await - .unwrap_or(None); - - let (did, key_bytes) = match session { - Some(row) => (row.did, row.key_bytes), + let token = match crate::auth::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(); + } + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed"})), @@ -52,14 +43,7 @@ pub async fn upload_blob( .into_response(); } }; - - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let did = auth_user.did; let mime_type = headers .get("content-type") @@ -182,32 +166,22 @@ pub async fn list_missing_blobs( headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", - token - ) - .fetch_optional(&state.db) - .await - .unwrap_or(None); - - let (did, key_bytes) = match session { - Some(row) => (row.did, row.key_bytes), + let token = match crate::auth::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(); + } + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed"})), @@ -216,13 +190,7 @@ pub async fn list_missing_blobs( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let did = auth_user.did; let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 8fba0a5..3c88124 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -73,31 +73,22 @@ pub async fn apply_writes( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", - token - ) - .fetch_optional(&state.db) - .await - .unwrap_or(None); - - let (did, key_bytes) = match session { - Some(row) => (row.did, row.key_bytes), + let token = match crate::auth::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(); + } + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed"})), @@ -106,13 +97,7 @@ pub async fn apply_writes( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let did = auth_user.did; if input.repo != did { return ( diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index eef5a93..ff01c97 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -23,45 +23,27 @@ pub async fn prepare_repo_write( headers: &HeaderMap, repo_did: &str, ) -> Result<(String, Uuid, Cid), Response> { - let auth_header = headers.get("Authorization").ok_or_else(|| { + let token = crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()) + ).ok_or_else(|| { ( StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationRequired"})), ) .into_response() })?; - let token = auth_header - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1", - token - ) - .fetch_optional(&state.db) - .await - .map_err(|e| { - error!("DB error fetching session: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() - })? - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response() - })?; + let auth_user = crate::auth::validate_bearer_token(&state.db, &token) + .await + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response() + })?; - crate::auth::verify_token(&token, &session.key_bytes).map_err(|_| { - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response() - })?; - - if repo_did != session.did { + if repo_did != auth_user.did { return Err(( StatusCode::FORBIDDEN, Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), @@ -69,7 +51,7 @@ pub async fn prepare_repo_write( .into_response()); } - let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", session.did) + let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) .fetch_optional(&state.db) .await .map_err(|e| { @@ -108,7 +90,7 @@ pub async fn prepare_repo_write( .into_response() })?; - Ok((session.did, user_id, current_root_cid)) + Ok((auth_user.did, user_id, current_root_cid)) } #[derive(Deserialize)] diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index e3827f6..3c1cfcf 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -30,45 +30,37 @@ pub async fn check_account_status( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in check_account_status: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -77,14 +69,6 @@ pub async fn check_account_status( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await; @@ -139,60 +123,30 @@ pub async fn activate_account( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes) = match session { - Ok(Some(row)) => (row.did, row.key_bytes), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in activate_account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) .execute(&state.db) @@ -222,60 +176,30 @@ pub async fn deactivate_account( headers: axum::http::HeaderMap, Json(_input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes) = match session { - Ok(Some(row)) => (row.did, row.key_bytes), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in deactivate_account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did) .execute(&state.db) @@ -298,45 +222,37 @@ pub async fn request_account_delete( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, u.id as user_id, u.email, u.handle, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, user_id, email, handle, key_bytes) = match session { - Ok(Some(row)) => (row.did, row.user_id, row.email, row.handle, row.key_bytes), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in request_account_delete: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user = match sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -344,14 +260,9 @@ pub async fn request_account_delete( .into_response(); } }; - - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let user_id = user.id; + let email = user.email; + let handle = user.handle; let confirmation_token = Uuid::new_v4().to_string(); let expires_at = Utc::now() + Duration::minutes(15); @@ -541,7 +452,7 @@ pub async fn delete_account( }; let deletion_result: Result<(), sqlx::Error> = async { - sqlx::query!("DELETE FROM sessions WHERE did = $1", did) + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) .execute(&mut *tx) .await?; diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 12a8680..a599322 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -26,45 +26,37 @@ pub async fn list_app_passwords( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in list_app_passwords: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -73,14 +65,6 @@ pub async fn list_app_passwords( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let result = sqlx::query!("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", user_id) .fetch_all(&state.db) .await; @@ -131,45 +115,37 @@ pub async fn create_app_password( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in create_app_password: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -178,14 +154,6 @@ pub async fn create_app_password( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let name = input.name.trim(); if name.is_empty() { return ( @@ -275,45 +243,37 @@ pub async fn revoke_app_password( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in revoke_app_password: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -322,14 +282,6 @@ pub async fn revoke_app_password( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let name = input.name.trim(); if name.is_empty() { return ( diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 972c26b..03ec402 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -30,45 +30,37 @@ pub async fn request_email_update( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id, u.handle - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id, handle) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in request_email_update: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -76,14 +68,8 @@ pub async fn request_email_update( .into_response(); } }; - - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let user_id = user.id; + let handle = user.handle; let email = input.email.trim().to_lowercase(); if email.is_empty() { @@ -159,52 +145,40 @@ pub async fn confirm_email( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session { - Ok(Some(row)) => ( - row.did, - row.key_bytes, - row.user_id, - row.email_confirmation_code, - row.email_confirmation_code_expires_at, - row.email_pending_verification, - ), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in confirm_email: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user = match sqlx::query!( + "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -212,14 +186,10 @@ pub async fn confirm_email( .into_response(); } }; - - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let user_id = user.id; + let stored_code = user.email_confirmation_code; + let expires_at = user.email_confirmation_code_expires_at; + let email_pending_verification = user.email_pending_verification; let email = input.email.trim().to_lowercase(); let confirmation_code = input.token.trim(); @@ -301,63 +271,40 @@ pub async fn update_email( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email, - u.email_confirmation_code, u.email_confirmation_code_expires_at, - u.email_pending_verification - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let ( - _did, - key_bytes, - user_id, - current_email, - stored_code, - expires_at, - email_pending_verification, - ) = match session { - Ok(Some(row)) => ( - row.did, - row.key_bytes, - row.user_id, - row.current_email, - row.email_confirmation_code, - row.email_confirmation_code_expires_at, - row.email_pending_verification, - ), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in update_email: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user = match sqlx::query!( + "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -365,14 +312,11 @@ pub async fn update_email( .into_response(); } }; - - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let user_id = user.id; + let current_email = user.email; + let stored_code = user.email_confirmation_code; + let expires_at = user.email_confirmation_code_expires_at; + let email_pending_verification = user.email_pending_verification; let new_email = input.email.trim().to_lowercase(); if new_email.is_empty() { diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index 7d58333..e5f298a 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -27,14 +27,18 @@ pub async fn create_invite_code( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + let token = match crate::auth::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 input.use_count < 1 { return ( @@ -44,36 +48,24 @@ pub async fn create_invite_code( .into_response(); } - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { return ( StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), + Json(json!({"error": e})), ) .into_response(); } - Err(e) => { - error!("DB error in create_invite_code: {:?}", e); + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -82,14 +74,6 @@ pub async fn create_invite_code( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let creator_user_id = if let Some(for_account) = &input.for_account { let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) .fetch_optional(&state.db) @@ -184,14 +168,18 @@ pub async fn create_invite_codes( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + let token = match crate::auth::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 input.use_count < 1 { return ( @@ -201,36 +189,24 @@ pub async fn create_invite_codes( .into_response(); } - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (_did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { return ( StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), + Json(json!({"error": e})), ) .into_response(); } - Err(e) => { - error!("DB error in create_invite_codes: {:?}", e); + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -239,14 +215,6 @@ pub async fn create_invite_codes( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let code_count = input.code_count.unwrap_or(1).max(1); let for_accounts = input.for_accounts.unwrap_or_default(); @@ -374,45 +342,37 @@ pub async fn get_account_invite_codes( headers: axum::http::HeaderMap, axum::extract::Query(params): axum::extract::Query, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes, u.id as user_id - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes, user_id) = match session { - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } + }; + + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, Err(e) => { - error!("DB error in get_account_invite_codes: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(id)) => id, + _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -421,14 +381,6 @@ pub async fn get_account_invite_codes( } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } - let include_used = params.include_used.unwrap_or(true); let codes_result = sqlx::query!( diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 8b2468c..f037651 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -211,7 +211,7 @@ pub async fn reset_password( .into_response(); } - let _ = sqlx::query!("DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) + let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) .execute(&state.db) .await; diff --git a/src/api/server/session.rs b/src/api/server/session.rs index cbf88d2..8d65537 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -27,60 +27,42 @@ pub async fn get_service_auth( headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - - let session = sqlx::query!( - r#" - SELECT s.did, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token - ) - .fetch_optional(&state.db) - .await; - - let (did, key_bytes) = match session { - Ok(Some(row)) => (row.did, row.key_bytes), - Ok(None) => { + let token = match crate::auth::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": "AuthenticationFailed"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in get_service_auth: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + Json(json!({"error": "AuthenticationRequired"})), ) .into_response(); } }; - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), - ) - .into_response(); - } + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let (did, key_bytes) = match auth_result { + Ok(user) => { + let kb = match user.key_bytes { + Some(kb) => kb, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot create service auth"})), + ) + .into_response(); + } + }; + (user.did, kb) + } + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; let lxm = params.lxm.as_deref().unwrap_or("*"); @@ -122,7 +104,7 @@ pub async fn create_session( info!("create_session: identifier='{}'", input.identifier); let user_row = sqlx::query!( - "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", + "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", input.identifier ) .fetch_optional(&state.db) @@ -134,7 +116,17 @@ pub async fn create_session( let stored_hash = &row.password_hash; let did = &row.did; let handle = &row.handle; - let key_bytes = &row.key_bytes; + let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; let password_valid = if verify(&input.password, stored_hash).unwrap_or(false) { true @@ -150,8 +142,8 @@ pub async fn create_session( }; if password_valid { - let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { - Ok(t) => t, + let access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { + Ok(m) => m, Err(e) => { error!("Failed to create access token: {:?}", e); return ( @@ -162,8 +154,8 @@ pub async fn create_session( } }; - let refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { - Ok(t) => t, + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { + Ok(m) => m, Err(e) => { error!("Failed to create refresh token: {:?}", e); return ( @@ -175,10 +167,12 @@ pub async fn create_session( }; let session_insert = sqlx::query!( - "INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", - access_jwt, - refresh_jwt, - did + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + did, + access_meta.jti, + refresh_meta.jti, + access_meta.expires_at, + refresh_meta.expires_at ) .execute(&state.db) .await; @@ -188,8 +182,8 @@ pub async fn create_session( return ( StatusCode::OK, Json(CreateSessionOutput { - access_jwt, - refresh_jwt, + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, handle: handle.clone(), did: did.clone(), }), @@ -236,45 +230,45 @@ pub async fn get_session( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + let token = match crate::auth::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", "message": "Invalid Authorization header format"})), + ) + .into_response(); + } + }; - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; + let did = match auth_result { + Ok(user) => user.did, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": e})), + ) + .into_response(); + } + }; - let result = sqlx::query!( - r#" - SELECT u.handle, u.did, u.email, k.key_bytes - FROM sessions s - JOIN users u ON s.did = u.did - JOIN user_keys k ON u.id = k.user_id - WHERE s.access_jwt = $1 - "#, - token + let user = sqlx::query!( + "SELECT handle, email FROM users WHERE did = $1", + did ) .fetch_optional(&state.db) .await; - match result { + match user { Ok(Some(row)) => { - if let Err(_) = crate::auth::verify_token(&token, &row.key_bytes) { - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"}))).into_response(); - } - return ( StatusCode::OK, Json(json!({ "handle": row.handle, - "did": row.did, + "did": did, "email": row.email, "didDoc": {} })), @@ -303,22 +297,71 @@ pub async fn delete_session( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + let token = match crate::auth::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(); + } + }; - let token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); + let jti = match crate::auth::get_did_from_token(&token) { + Ok(_) => { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + let claims_json = match URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(bytes) => bytes, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; + let claims: serde_json::Value = match serde_json::from_slice(&claims_json) { + Ok(c) => c, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; + match claims.get("jti").and_then(|j| j.as_str()) { + Some(jti) => jti.to_string(), + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + } + } + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; - let result = sqlx::query!("DELETE FROM sessions WHERE access_jwt = $1", token) + let result = sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) .execute(&state.db) .await; @@ -344,39 +387,114 @@ pub async fn refresh_session( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let refresh_token = match crate::auth::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(); + } + }; + + let refresh_jti = { + let parts: Vec<&str> = refresh_token.split('.').collect(); + if parts.len() != 3 { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token format"})), + ) + .into_response(); + } + let claims_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(b) => b, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; + let claims: serde_json::Value = match serde_json::from_slice(&claims_bytes) { + Ok(c) => c, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; + match claims.get("jti").and_then(|j| j.as_str()) { + Some(jti) => jti.to_string(), + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + } + }; + + let reuse_check = sqlx::query_scalar!( + "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + refresh_jti + ) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(session_id)) = reuse_check { + warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id); + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) + .execute(&state.db) + .await; return ( StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), + Json(json!({"error": "ExpiredToken", "message": "Refresh token has been revoked due to suspected compromise"})), ) .into_response(); } - let refresh_token = auth_header - .unwrap() - .to_str() - .unwrap_or("") - .replace("Bearer ", ""); - let session = sqlx::query!( - "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.refresh_jwt = $1", - refresh_token - ) - .fetch_optional(&state.db) - .await; + r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version + FROM session_tokens st + JOIN users u ON st.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()"#, + refresh_jti + ) + .fetch_optional(&state.db) + .await; match session { Ok(Some(session_row)) => { + let session_id = session_row.id; let did = &session_row.did; - let key_bytes = &session_row.key_bytes; + let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; - if let Err(_) = crate::auth::verify_token(&refresh_token, &key_bytes) { - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token signature"}))).into_response(); + if let Err(_) = crate::auth::verify_refresh_token(&refresh_token, &key_bytes) { + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"}))).into_response(); } - let new_access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { - Ok(t) => t, + let new_access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { + Ok(m) => m, Err(e) => { error!("Failed to create access token: {:?}", e); return ( @@ -386,8 +504,8 @@ pub async fn refresh_session( .into_response(); } }; - let new_refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { - Ok(t) => t, + let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { + Ok(m) => m, Err(e) => { error!("Failed to create refresh token: {:?}", e); return ( @@ -398,54 +516,89 @@ pub async fn refresh_session( } }; - let update = sqlx::query!( - "UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3", - new_access_jwt, - new_refresh_jwt, - refresh_token + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", + refresh_jti, + session_id ) - .execute(&state.db) - .await; + .execute(&mut *tx) + .await + { + error!("Failed to record used refresh token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } - match update { - Ok(_) => { - let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await; + if let Err(e) = sqlx::query!( + "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", + new_access_meta.jti, + new_refresh_meta.jti, + new_access_meta.expires_at, + new_refresh_meta.expires_at, + session_id + ) + .execute(&mut *tx) + .await + { + error!("Database error updating session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } - match user { - Ok(Some(u)) => { - return ( - StatusCode::OK, - Json(json!({ - "accessJwt": new_access_jwt, - "refreshJwt": new_refresh_jwt, - "handle": u.handle, - "did": did - })), - ) - .into_response(); - } - Ok(None) => { - error!("User not found for existing session: {}", did); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - Err(e) => { - error!("Database error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - } + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await; + + match user { + Ok(Some(u)) => { + return ( + StatusCode::OK, + Json(json!({ + "accessJwt": new_access_meta.token, + "refreshJwt": new_refresh_meta.token, + "handle": u.handle, + "did": did + })), + ) + .into_response(); + } + Ok(None) => { + error!("User not found for existing session: {}", did); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); } Err(e) => { - error!("Database error updating session: {:?}", e); + error!("Database error fetching user: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs new file mode 100644 index 0000000..c09e6d3 --- /dev/null +++ b/src/auth/extractor.rs @@ -0,0 +1,140 @@ +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts, header::AUTHORIZATION}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use crate::state::AppState; +use super::{AuthenticatedUser, validate_bearer_token}; + +pub struct BearerAuth(pub AuthenticatedUser); + +#[derive(Debug)] +pub enum AuthError { + MissingToken, + InvalidFormat, + AuthenticationFailed, + AccountDeactivated, + AccountTakedown, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, error, message) = match self { + AuthError::MissingToken => ( + StatusCode::UNAUTHORIZED, + "AuthenticationRequired", + "Authorization header is required", + ), + AuthError::InvalidFormat => ( + StatusCode::UNAUTHORIZED, + "InvalidToken", + "Invalid authorization header format", + ), + AuthError::AuthenticationFailed => ( + StatusCode::UNAUTHORIZED, + "AuthenticationFailed", + "Invalid or expired token", + ), + AuthError::AccountDeactivated => ( + StatusCode::UNAUTHORIZED, + "AccountDeactivated", + "Account is deactivated", + ), + AuthError::AccountTakedown => ( + StatusCode::UNAUTHORIZED, + "AccountTakedown", + "Account has been taken down", + ), + }; + + (status, Json(json!({ "error": error, "message": message }))).into_response() + } +} + +fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> { + let auth_header = auth_header.trim(); + + if auth_header.len() < 8 { + return Err(AuthError::InvalidFormat); + } + + let prefix = &auth_header[..7]; + if !prefix.eq_ignore_ascii_case("bearer ") { + return Err(AuthError::InvalidFormat); + } + + let token = auth_header[7..].trim(); + if token.is_empty() { + return Err(AuthError::InvalidFormat); + } + + Ok(token) +} + +pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option { + let header = auth_header?; + let header = header.trim(); + + if header.len() < 7 { + return None; + } + + if !header[..7].eq_ignore_ascii_case("bearer ") { + return None; + } + + let token = header[7..].trim(); + if token.is_empty() { + return None; + } + + Some(token.to_string()) +} + +impl FromRequestParts for BearerAuth { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or(AuthError::MissingToken)? + .to_str() + .map_err(|_| AuthError::InvalidFormat)?; + + let token = extract_bearer_token(auth_header)?; + + match validate_bearer_token(&state.db, token).await { + Ok(user) => Ok(BearerAuth(user)), + Err("AccountDeactivated") => Err(AuthError::AccountDeactivated), + Err("AccountTakedown") => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_bearer_token() { + assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123"); + assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123"); + assert_eq!(extract_bearer_token("BEARER abc123").unwrap(), "abc123"); + assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123"); + assert_eq!(extract_bearer_token(" Bearer abc123 ").unwrap(), "abc123"); + + assert!(extract_bearer_token("Basic abc123").is_err()); + assert!(extract_bearer_token("Bearer").is_err()); + assert!(extract_bearer_token("Bearer ").is_err()); + assert!(extract_bearer_token("abc123").is_err()); + assert!(extract_bearer_token("").is_err()); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 790d81b..478e8da 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,10 +1,127 @@ use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +pub mod extractor; pub mod token; pub mod verify; -pub use token::{create_access_token, create_refresh_token, create_service_token}; -pub use verify::{get_did_from_token, verify_token}; +pub use extractor::{BearerAuth, AuthError, extract_bearer_token_from_header}; +pub use token::{ + create_access_token, create_refresh_token, create_service_token, + create_access_token_with_metadata, create_refresh_token_with_metadata, + TokenWithMetadata, + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, + SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, +}; +pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token}; + +pub struct AuthenticatedUser { + pub did: String, + pub key_bytes: Option>, + pub is_oauth: bool, +} + +pub async fn validate_bearer_token( + db: &PgPool, + token: &str, +) -> Result { + validate_bearer_token_with_options(db, token, false).await +} + +pub async fn validate_bearer_token_allow_deactivated( + db: &PgPool, + token: &str, +) -> Result { + validate_bearer_token_with_options(db, token, true).await +} + +async fn validate_bearer_token_with_options( + db: &PgPool, + token: &str, + allow_deactivated: bool, +) -> Result { + let did_from_token = get_did_from_token(token).ok(); + + if let Some(ref did) = did_from_token { + if let Some(user) = sqlx::query!( + "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1", + did + ) + .fetch_optional(db) + .await + .ok() + .flatten() + { + if !allow_deactivated && user.deactivated_at.is_some() { + return Err("AccountDeactivated"); + } + if user.takedown_ref.is_some() { + return Err("AccountTakedown"); + } + + let decrypted_key = match crate::config::decrypt_key(&user.key_bytes, user.encryption_version) { + Ok(k) => k, + Err(_) => return Err("KeyDecryptionFailed"), + }; + + if let Ok(token_data) = verify_access_token(token, &decrypted_key) { + let session_exists = sqlx::query_scalar!( + "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", + did, + token_data.claims.jti + ) + .fetch_optional(db) + .await + .ok() + .flatten(); + + if session_exists.is_some() { + return Ok(AuthenticatedUser { + did: did.clone(), + key_bytes: Some(decrypted_key), + is_oauth: false, + }); + } + } + } + } + + if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) { + if let Some(oauth_token) = sqlx::query!( + r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref + FROM oauth_token t + JOIN users u ON t.did = u.did + WHERE t.token_id = $1"#, + oauth_info.token_id + ) + .fetch_optional(db) + .await + .ok() + .flatten() + { + if !allow_deactivated && oauth_token.deactivated_at.is_some() { + return Err("AccountDeactivated"); + } + if oauth_token.takedown_ref.is_some() { + return Err("AccountTakedown"); + } + + let now = chrono::Utc::now(); + if oauth_token.expires_at > now { + return Ok(AuthenticatedUser { + did: oauth_token.did, + key_bytes: None, + is_oauth: true, + }); + } + } + } + + Err("AuthenticationFailed") +} #[derive(Debug, Serialize, Deserialize)] pub struct Claims { diff --git a/src/auth/token.rs b/src/auth/token.rs index 9568918..0914b69 100644 --- a/src/auth/token.rs +++ b/src/auth/token.rs @@ -2,16 +2,39 @@ use super::{Claims, Header}; use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use uuid; +pub const TOKEN_TYPE_ACCESS: &str = "at+jwt"; +pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt"; +pub const TOKEN_TYPE_SERVICE: &str = "jwt"; + +pub const SCOPE_ACCESS: &str = "com.atproto.access"; +pub const SCOPE_REFRESH: &str = "com.atproto.refresh"; +pub const SCOPE_APP_PASS: &str = "com.atproto.appPass"; +pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged"; + +pub struct TokenWithMetadata { + pub token: String, + pub jti: String, + pub expires_at: DateTime, +} + pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result { - create_signed_token(did, "access", key_bytes, Duration::minutes(15)) + Ok(create_access_token_with_metadata(did, key_bytes)?.token) } pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result { - create_signed_token(did, "refresh", key_bytes, Duration::days(7)) + Ok(create_refresh_token_with_metadata(did, key_bytes)?.token) +} + +pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result { + create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120)) +} + +pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result { + create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90)) } pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result { @@ -36,18 +59,20 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) - sign_claims(claims, &signing_key) } -fn create_signed_token( +fn create_signed_token_with_metadata( did: &str, scope: &str, + typ: &str, key_bytes: &[u8], duration: Duration, -) -> Result { +) -> Result { let signing_key = SigningKey::from_slice(key_bytes)?; - let expiration = Utc::now() + let expires_at = Utc::now() .checked_add_signed(duration) - .expect("valid timestamp") - .timestamp(); + .expect("valid timestamp"); + let expiration = expires_at.timestamp(); + let jti = uuid::Uuid::new_v4().to_string(); let claims = Claims { iss: did.to_owned(), @@ -60,16 +85,25 @@ fn create_signed_token( iat: Utc::now().timestamp() as usize, scope: Some(scope.to_string()), lxm: None, - jti: uuid::Uuid::new_v4().to_string(), + jti: jti.clone(), }; - sign_claims(claims, &signing_key) + let token = sign_claims_with_type(claims, &signing_key, typ)?; + Ok(TokenWithMetadata { + token, + jti, + expires_at, + }) } fn sign_claims(claims: Claims, key: &SigningKey) -> Result { + sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE) +} + +fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result { let header = Header { alg: "ES256K".to_string(), - typ: "JWT".to_string(), + typ: typ.to_string(), }; let header_json = serde_json::to_string(&header)?; diff --git a/src/auth/verify.rs b/src/auth/verify.rs index 5956984..41e1a0c 100644 --- a/src/auth/verify.rs +++ b/src/auth/verify.rs @@ -1,4 +1,5 @@ -use super::{Claims, TokenData, UnsafeClaims}; +use super::{Claims, Header, TokenData, UnsafeClaims}; +use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED}; use anyhow::{Context, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -21,7 +22,53 @@ pub fn get_did_from_token(token: &str) -> Result { Ok(claims.sub.unwrap_or(claims.iss)) } +pub fn get_jti_from_token(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err("Invalid token format".to_string()); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| format!("Base64 decode failed: {}", e))?; + + let claims: serde_json::Value = + serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; + + claims.get("jti") + .and_then(|j| j.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "No jti claim in token".to_string()) +} + pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result> { + verify_token_internal(token, key_bytes, None, None) +} + +pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result> { + verify_token_internal( + token, + key_bytes, + Some(TOKEN_TYPE_ACCESS), + Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]), + ) +} + +pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result> { + verify_token_internal( + token, + key_bytes, + Some(TOKEN_TYPE_REFRESH), + Some(&[SCOPE_REFRESH]), + ) +} + +fn verify_token_internal( + token: &str, + key_bytes: &[u8], + expected_typ: Option<&str>, + allowed_scopes: Option<&[&str]>, +) -> Result> { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return Err(anyhow!("Invalid token format")); @@ -31,6 +78,18 @@ pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result> let claims_b64 = parts[1]; let signature_b64 = parts[2]; + let header_bytes = URL_SAFE_NO_PAD + .decode(header_b64) + .context("Base64 decode of header failed")?; + let header: Header = + serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?; + + if let Some(expected) = expected_typ { + if header.typ != expected { + return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ)); + } + } + let signature_bytes = URL_SAFE_NO_PAD .decode(signature_b64) .context("Base64 decode of signature failed")?; @@ -56,5 +115,12 @@ pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result> return Err(anyhow!("Token expired")); } + if let Some(scopes) = allowed_scopes { + let token_scope = claims.scope.as_deref().unwrap_or(""); + if !scopes.contains(&token_scope) { + return Err(anyhow!("Invalid token scope: {}", token_scope)); + } + } + Ok(TokenData { claims }) } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e0f5ac5 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,170 @@ +use aes_gcm::{ + Aes256Gcm, KeyInit, Nonce, + aead::Aead, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hkdf::Hkdf; +use p256::ecdsa::SigningKey; +use sha2::{Digest, Sha256}; +use std::sync::OnceLock; + +static CONFIG: OnceLock = OnceLock::new(); + +pub const ENCRYPTION_VERSION: i32 = 1; + +pub struct AuthConfig { + jwt_secret: String, + dpop_secret: String, + #[allow(dead_code)] + signing_key: SigningKey, + pub signing_key_id: String, + pub signing_key_x: String, + pub signing_key_y: String, + key_encryption_key: [u8; 32], +} + +impl AuthConfig { + pub fn init() -> &'static Self { + CONFIG.get_or_init(|| { + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { + "test-jwt-secret-not-for-production".to_string() + } else { + panic!( + "JWT_SECRET environment variable must be set in production. \ + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." + ); + } + }); + + let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| { + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { + "test-dpop-secret-not-for-production".to_string() + } else { + panic!( + "DPOP_SECRET environment variable must be set in production. \ + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." + ); + } + }); + + if jwt_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { + panic!("JWT_SECRET must be at least 32 characters"); + } + if dpop_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { + panic!("DPOP_SECRET must be at least 32 characters"); + } + + let mut hasher = Sha256::new(); + hasher.update(b"oauth-signing-key-derivation:"); + hasher.update(jwt_secret.as_bytes()); + let seed = hasher.finalize(); + + let signing_key = SigningKey::from_slice(&seed) + .expect("Failed to create signing key from seed"); + + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let signing_key_x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let signing_key_y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let mut kid_hasher = Sha256::new(); + kid_hasher.update(signing_key_x.as_bytes()); + kid_hasher.update(signing_key_y.as_bytes()); + let kid_hash = kid_hasher.finalize(); + let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]); + + let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { + "test-master-key-not-for-production".to_string() + } else { + panic!( + "MASTER_KEY environment variable must be set in production. \ + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." + ); + } + }); + + if master_key.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { + panic!("MASTER_KEY must be at least 32 characters"); + } + + let hk = Hkdf::::new(None, master_key.as_bytes()); + let mut key_encryption_key = [0u8; 32]; + hk.expand(b"bspds-user-key-encryption", &mut key_encryption_key) + .expect("HKDF expansion failed"); + + AuthConfig { + jwt_secret, + dpop_secret, + signing_key, + signing_key_id, + signing_key_x, + signing_key_y, + key_encryption_key, + } + }) + } + + pub fn get() -> &'static Self { + CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first") + } + + pub fn jwt_secret(&self) -> &str { + &self.jwt_secret + } + + pub fn dpop_secret(&self) -> &str { + &self.dpop_secret + } + + pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result, String> { + use rand::RngCore; + + let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("Encryption failed: {}", e))?; + + let mut result = Vec::with_capacity(12 + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + + Ok(result) + } + + pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result, String> { + if encrypted.len() < 12 { + return Err("Encrypted data too short".to_string()); + } + + let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + let nonce = Nonce::from_slice(&encrypted[..12]); + let ciphertext = &encrypted[12..]; + + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {}", e)) + } +} + +pub fn encrypt_key(plaintext: &[u8]) -> Result, String> { + AuthConfig::get().encrypt_user_key(plaintext) +} + +pub fn decrypt_key(encrypted: &[u8], version: Option) -> Result, String> { + match version.unwrap_or(0) { + 0 => Ok(encrypted.to_vec()), + 1 => AuthConfig::get().decrypt_user_key(encrypted), + v => Err(format!("Unknown encryption version: {}", v)), + } +} diff --git a/src/lib.rs b/src/lib.rs index be60215..0e1c150 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ pub mod api; pub mod auth; +pub mod config; pub mod notifications; +pub mod oauth; pub mod repo; pub mod state; pub mod storage; @@ -267,6 +269,25 @@ pub fn app(state: AppState) -> Router { ) .route("/.well-known/did.json", get(api::identity::well_known_did)) .route("/u/{handle}/did.json", get(api::identity::user_did_doc)) + // OAuth 2.1 endpoints + .route( + "/.well-known/oauth-protected-resource", + get(oauth::endpoints::oauth_protected_resource), + ) + .route( + "/.well-known/oauth-authorization-server", + get(oauth::endpoints::oauth_authorization_server), + ) + .route("/oauth/jwks", get(oauth::endpoints::oauth_jwks)) + .route( + "/oauth/par", + post(oauth::endpoints::pushed_authorization_request), + ) + .route("/oauth/authorize", get(oauth::endpoints::authorize_get)) + .route("/oauth/authorize", post(oauth::endpoints::authorize_post)) + .route("/oauth/token", post(oauth::endpoints::token_endpoint)) + .route("/oauth/revoke", post(oauth::endpoints::revoke_token)) + .route("/oauth/introspect", post(oauth::endpoints::introspect_token)) .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) .with_state(state) } diff --git a/src/oauth/client.rs b/src/oauth/client.rs new file mode 100644 index 0000000..6e2459a --- /dev/null +++ b/src/oauth/client.rs @@ -0,0 +1,365 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +use super::OAuthError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientMetadata { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_uri: Option, + pub redirect_uris: Vec, + #[serde(default)] + pub grant_types: Vec, + #[serde(default)] + pub response_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dpop_bound_access_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub application_type: Option, +} + +impl Default for ClientMetadata { + fn default() -> Self { + Self { + client_id: String::new(), + client_name: None, + client_uri: None, + logo_uri: None, + redirect_uris: Vec::new(), + grant_types: vec!["authorization_code".to_string()], + response_types: vec!["code".to_string()], + scope: None, + token_endpoint_auth_method: Some("none".to_string()), + dpop_bound_access_tokens: None, + jwks: None, + jwks_uri: None, + application_type: None, + } + } +} + +#[derive(Clone)] +pub struct ClientMetadataCache { + cache: Arc>>, + http_client: Client, + cache_ttl_secs: u64, +} + +struct CachedMetadata { + metadata: ClientMetadata, + cached_at: std::time::Instant, +} + +impl ClientMetadataCache { + pub fn new(cache_ttl_secs: u64) -> Self { + Self { + cache: Arc::new(RwLock::new(HashMap::new())), + http_client: Client::new(), + cache_ttl_secs, + } + } + + pub async fn get(&self, client_id: &str) -> Result { + { + let cache = self.cache.read().await; + if let Some(cached) = cache.get(client_id) { + if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { + return Ok(cached.metadata.clone()); + } + } + } + + let metadata = self.fetch_metadata(client_id).await?; + + { + let mut cache = self.cache.write().await; + cache.insert( + client_id.to_string(), + CachedMetadata { + metadata: metadata.clone(), + cached_at: std::time::Instant::now(), + }, + ); + } + + Ok(metadata) + } + + async fn fetch_metadata(&self, client_id: &str) -> Result { + if !client_id.starts_with("http://") && !client_id.starts_with("https://") { + return Err(OAuthError::InvalidClient( + "client_id must be a URL".to_string(), + )); + } + + if client_id.starts_with("http://") + && !client_id.contains("localhost") + && !client_id.contains("127.0.0.1") + { + return Err(OAuthError::InvalidClient( + "Non-localhost client_id must use https".to_string(), + )); + } + + let response = self + .http_client + .get(client_id) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?; + + if !response.status().is_success() { + return Err(OAuthError::InvalidClient(format!( + "Failed to fetch client metadata: HTTP {}", + response.status() + ))); + } + + let mut metadata: ClientMetadata = response + .json() + .await + .map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?; + + if metadata.client_id.is_empty() { + metadata.client_id = client_id.to_string(); + } else if metadata.client_id != client_id { + return Err(OAuthError::InvalidClient( + "client_id in metadata does not match request".to_string(), + )); + } + + self.validate_metadata(&metadata)?; + + Ok(metadata) + } + + fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> { + if metadata.redirect_uris.is_empty() { + return Err(OAuthError::InvalidClient( + "redirect_uris is required".to_string(), + )); + } + + for uri in &metadata.redirect_uris { + self.validate_redirect_uri_format(uri)?; + } + + if !metadata.grant_types.is_empty() + && !metadata.grant_types.contains(&"authorization_code".to_string()) + { + return Err(OAuthError::InvalidClient( + "authorization_code grant type is required".to_string(), + )); + } + + if !metadata.response_types.is_empty() + && !metadata.response_types.contains(&"code".to_string()) + { + return Err(OAuthError::InvalidClient( + "code response type is required".to_string(), + )); + } + + Ok(()) + } + + pub fn validate_redirect_uri( + &self, + metadata: &ClientMetadata, + redirect_uri: &str, + ) -> Result<(), OAuthError> { + if !metadata.redirect_uris.contains(&redirect_uri.to_string()) { + return Err(OAuthError::InvalidRequest( + "redirect_uri not registered for client".to_string(), + )); + } + Ok(()) + } + + fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> { + if uri.contains('#') { + return Err(OAuthError::InvalidClient( + "redirect_uri must not contain a fragment".to_string(), + )); + } + + let parsed = reqwest::Url::parse(uri).map_err(|_| { + OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)) + })?; + + let scheme = parsed.scheme(); + + if scheme == "http" { + let host = parsed.host_str().unwrap_or(""); + if host != "localhost" && host != "127.0.0.1" && host != "[::1]" { + return Err(OAuthError::InvalidClient( + "http redirect_uri only allowed for localhost".to_string(), + )); + } + } else if scheme == "https" { + } else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') { + if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { + return Err(OAuthError::InvalidClient(format!( + "Invalid redirect_uri scheme: {}", + scheme + ))); + } + } else { + return Err(OAuthError::InvalidClient(format!( + "Invalid redirect_uri scheme: {}", + scheme + ))); + } + + Ok(()) + } +} + +impl ClientMetadata { + pub fn requires_dpop(&self) -> bool { + self.dpop_bound_access_tokens.unwrap_or(false) + } + + pub fn auth_method(&self) -> &str { + self.token_endpoint_auth_method + .as_deref() + .unwrap_or("none") + } +} + +pub fn verify_client_auth( + metadata: &ClientMetadata, + client_auth: &super::ClientAuth, +) -> Result<(), OAuthError> { + let expected_method = metadata.auth_method(); + + match (expected_method, client_auth) { + ("none", super::ClientAuth::None) => Ok(()), + + ("none", _) => Err(OAuthError::InvalidClient( + "Client is configured for no authentication, but credentials were provided".to_string(), + )), + + ("private_key_jwt", super::ClientAuth::PrivateKeyJwt { client_assertion }) => { + verify_private_key_jwt(metadata, client_assertion) + } + + ("private_key_jwt", _) => Err(OAuthError::InvalidClient( + "Client requires private_key_jwt authentication".to_string(), + )), + + ("client_secret_post", super::ClientAuth::SecretPost { .. }) => { + Err(OAuthError::InvalidClient( + "client_secret_post is not supported for ATProto OAuth".to_string(), + )) + } + + ("client_secret_basic", super::ClientAuth::SecretBasic { .. }) => { + Err(OAuthError::InvalidClient( + "client_secret_basic is not supported for ATProto OAuth".to_string(), + )) + } + + (method, _) => Err(OAuthError::InvalidClient(format!( + "Unsupported or mismatched authentication method: {}", + method + ))), + } +} + +fn verify_private_key_jwt( + metadata: &ClientMetadata, + client_assertion: &str, +) -> Result<(), OAuthError> { + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + + let parts: Vec<&str> = client_assertion.split('.').collect(); + if parts.len() != 3 { + return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?; + + let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| { + OAuthError::InvalidClient("Missing alg in client_assertion".to_string()) + })?; + + if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") { + return Err(OAuthError::InvalidClient(format!( + "Unsupported client_assertion algorithm: {}", + alg + ))); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload encoding".to_string()))?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?; + + let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| { + OAuthError::InvalidClient("Missing iss in client_assertion".to_string()) + })?; + if iss != metadata.client_id { + return Err(OAuthError::InvalidClient( + "client_assertion iss does not match client_id".to_string(), + )); + } + + let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| { + OAuthError::InvalidClient("Missing sub in client_assertion".to_string()) + })?; + if sub != metadata.client_id { + return Err(OAuthError::InvalidClient( + "client_assertion sub does not match client_id".to_string(), + )); + } + + let exp = payload.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| { + OAuthError::InvalidClient("Missing exp in client_assertion".to_string()) + })?; + let now = chrono::Utc::now().timestamp(); + if exp < now { + return Err(OAuthError::InvalidClient("client_assertion has expired".to_string())); + } + + let iat = payload.get("iat").and_then(|i| i.as_i64()); + if let Some(iat) = iat { + if iat > now + 60 { + return Err(OAuthError::InvalidClient( + "client_assertion iat is in the future".to_string(), + )); + } + } + + if metadata.jwks.is_none() && metadata.jwks_uri.is_none() { + return Err(OAuthError::InvalidClient( + "Client using private_key_jwt must have jwks or jwks_uri".to_string(), + )); + } + + Err(OAuthError::InvalidClient( + "private_key_jwt signature verification not yet implemented - use 'none' auth method".to_string(), + )) +} diff --git a/src/oauth/db.rs b/src/oauth/db.rs new file mode 100644 index 0000000..b412e62 --- /dev/null +++ b/src/oauth/db.rs @@ -0,0 +1,641 @@ +use chrono::{DateTime, Utc}; +use serde::{de::DeserializeOwned, Serialize}; +use sqlx::PgPool; + +use super::{ + AuthorizationRequestParameters, ClientAuth, DeviceData, OAuthError, RequestData, TokenData, + AuthorizedClientData, +}; + +fn to_json(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| { + tracing::error!("JSON serialization error: {}", e); + OAuthError::ServerError("Internal serialization error".to_string()) + }) +} + +fn from_json(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| { + tracing::error!("JSON deserialization error: {}", e); + OAuthError::ServerError("Internal data corruption".to_string()) + }) +} + +pub async fn create_device( + pool: &PgPool, + device_id: &str, + data: &DeviceData, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at) + VALUES ($1, $2, $3, $4, $5) + "#, + device_id, + data.session_id, + data.user_agent, + data.ip_address, + data.last_seen_at, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_device(pool: &PgPool, device_id: &str) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT session_id, user_agent, ip_address, last_seen_at + FROM oauth_device + WHERE id = $1 + "#, + device_id + ) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| DeviceData { + session_id: r.session_id, + user_agent: r.user_agent, + ip_address: r.ip_address, + last_seen_at: r.last_seen_at, + })) +} + +pub async fn update_device_last_seen( + pool: &PgPool, + device_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_device + SET last_seen_at = NOW() + WHERE id = $1 + "#, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_device WHERE id = $1 + "#, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn create_authorization_request( + pool: &PgPool, + request_id: &str, + data: &RequestData, +) -> Result<(), OAuthError> { + let client_auth_json = match &data.client_auth { + Some(ca) => Some(to_json(ca)?), + None => None, + }; + let parameters_json = to_json(&data.parameters)?; + + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request + (id, did, device_id, client_id, client_auth, parameters, expires_at, code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + request_id, + data.did, + data.device_id, + data.client_id, + client_auth_json, + parameters_json, + data.expires_at, + data.code, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_authorization_request( + pool: &PgPool, + request_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT did, device_id, client_id, client_auth, parameters, expires_at, code + FROM oauth_authorization_request + WHERE id = $1 + "#, + request_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + })) + } + None => Ok(None), + } +} + +pub async fn update_authorization_request( + pool: &PgPool, + request_id: &str, + did: &str, + device_id: Option<&str>, + code: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3, code = $4 + WHERE id = $1 + "#, + request_id, + did, + device_id, + code + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn consume_authorization_request_by_code( + pool: &PgPool, + code: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE code = $1 + RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code + "#, + code + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + })) + } + None => Ok(None), + } +} + +pub async fn delete_authorization_request( + pool: &PgPool, + request_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_authorization_request WHERE id = $1 + "#, + request_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE expires_at < NOW() + "# + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +pub async fn create_token( + pool: &PgPool, + data: &TokenData, +) -> Result { + let client_auth_json = to_json(&data.client_auth)?; + let parameters_json = to_json(&data.parameters)?; + + let row = sqlx::query!( + r#" + INSERT INTO oauth_token + (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id + "#, + data.did, + data.token_id, + data.created_at, + data.updated_at, + data.expires_at, + data.client_id, + client_auth_json, + data.device_id, + parameters_json, + data.details, + data.code, + data.current_refresh_token, + data.scope, + ) + .fetch_one(pool) + .await?; + + Ok(row.id) +} + +pub async fn get_token_by_id( + pool: &PgPool, + token_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE token_id = $1 + "#, + token_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => Ok(Some(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + })), + None => Ok(None), + } +} + +pub async fn get_token_by_refresh_token( + pool: &PgPool, + refresh_token: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE current_refresh_token = $1 + "#, + refresh_token + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => Ok(Some(( + r.id, + TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + }, + ))), + None => Ok(None), + } +} + +pub async fn rotate_token( + pool: &PgPool, + old_db_id: i32, + new_token_id: &str, + new_refresh_token: &str, + new_expires_at: DateTime, +) -> Result<(), OAuthError> { + let mut tx = pool.begin().await?; + + let old_refresh = sqlx::query_scalar!( + r#" + SELECT current_refresh_token FROM oauth_token WHERE id = $1 + "#, + old_db_id + ) + .fetch_one(&mut *tx) + .await?; + + if let Some(old_rt) = old_refresh { + sqlx::query!( + r#" + INSERT INTO oauth_used_refresh_token (refresh_token, token_id) + VALUES ($1, $2) + "#, + old_rt, + old_db_id + ) + .execute(&mut *tx) + .await?; + } + + sqlx::query!( + r#" + UPDATE oauth_token + SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() + WHERE id = $1 + "#, + old_db_id, + new_token_id, + new_refresh_token, + new_expires_at + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +pub async fn check_refresh_token_used( + pool: &PgPool, + refresh_token: &str, +) -> Result, OAuthError> { + let row = sqlx::query_scalar!( + r#" + SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 + "#, + refresh_token + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE token_id = $1 + "#, + token_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE id = $1 + "#, + db_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn upsert_account_device( + pool: &PgPool, + did: &str, + device_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + INSERT INTO oauth_account_device (did, device_id, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW() + "#, + did, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn upsert_authorized_client( + pool: &PgPool, + did: &str, + client_id: &str, + data: &AuthorizedClientData, +) -> Result<(), OAuthError> { + let data_json = to_json(data)?; + + sqlx::query!( + r#" + INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data) + VALUES ($1, $2, NOW(), NOW(), $3) + ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3 + "#, + did, + client_id, + data_json + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_authorized_client( + pool: &PgPool, + did: &str, + client_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query_scalar!( + r#" + SELECT data FROM oauth_authorized_client + WHERE did = $1 AND client_id = $2 + "#, + did, + client_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(v) => Ok(Some(from_json(v)?)), + None => Ok(None), + } +} + +pub async fn list_tokens_for_user( + pool: &PgPool, + did: &str, +) -> Result, OAuthError> { + let rows = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE did = $1 + "#, + did + ) + .fetch_all(pool) + .await?; + + let mut tokens = Vec::with_capacity(rows.len()); + for r in rows { + tokens.push(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + }); + } + Ok(tokens) +} + +pub async fn check_and_record_dpop_jti( + pool: &PgPool, + jti: &str, +) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO oauth_dpop_jti (jti) + VALUES ($1) + ON CONFLICT (jti) DO NOTHING + "#, + jti + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn cleanup_expired_dpop_jtis( + pool: &PgPool, + max_age_secs: i64, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_dpop_jti + WHERE created_at < NOW() - INTERVAL '1 second' * $1 + "#, + max_age_secs as f64 + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result { + let count = sqlx::query_scalar!( + r#" + SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1 + "#, + did + ) + .fetch_one(pool) + .await?; + + Ok(count) +} + +pub async fn delete_oldest_tokens_for_user( + pool: &PgPool, + did: &str, + keep_count: i64, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_token + WHERE id IN ( + SELECT id FROM oauth_token + WHERE did = $1 + ORDER BY updated_at ASC + OFFSET $2 + ) + "#, + did, + keep_count + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +const MAX_TOKENS_PER_USER: i64 = 100; + +pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> { + let count = count_tokens_for_user(pool, did).await?; + if count > MAX_TOKENS_PER_USER { + let to_keep = MAX_TOKENS_PER_USER - 1; + delete_oldest_tokens_for_user(pool, did, to_keep).await?; + } + Ok(()) +} diff --git a/src/oauth/dpop.rs b/src/oauth/dpop.rs new file mode 100644 index 0000000..08641e6 --- /dev/null +++ b/src/oauth/dpop.rs @@ -0,0 +1,421 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::OAuthError; + +const DPOP_NONCE_VALIDITY_SECS: i64 = 300; +const DPOP_MAX_AGE_SECS: i64 = 300; + +#[derive(Debug, Clone)] +pub struct DPoPVerifyResult { + pub jkt: String, + pub jti: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DPoPProofHeader { + pub typ: String, + pub alg: String, + pub jwk: DPoPJwk, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DPoPJwk { + pub kty: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub crv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub y: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DPoPProofPayload { + pub jti: String, + pub htm: String, + pub htu: String, + pub iat: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub ath: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +pub struct DPoPVerifier { + secret: Vec, +} + +impl DPoPVerifier { + pub fn new(secret: &[u8]) -> Self { + Self { + secret: secret.to_vec(), + } + } + + pub fn generate_nonce(&self) -> String { + let timestamp = Utc::now().timestamp(); + let timestamp_bytes = timestamp.to_be_bytes(); + + let mut hasher = Sha256::new(); + hasher.update(&self.secret); + hasher.update(×tamp_bytes); + let hash = hasher.finalize(); + + let mut nonce_data = Vec::with_capacity(8 + 16); + nonce_data.extend_from_slice(×tamp_bytes); + nonce_data.extend_from_slice(&hash[..16]); + + URL_SAFE_NO_PAD.encode(&nonce_data) + } + + pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> { + let nonce_bytes = URL_SAFE_NO_PAD + .decode(nonce) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?; + + if nonce_bytes.len() < 24 { + return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string())); + } + + let timestamp_bytes: [u8; 8] = nonce_bytes[..8] + .try_into() + .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce".to_string()))?; + let timestamp = i64::from_be_bytes(timestamp_bytes); + + let now = Utc::now().timestamp(); + if now - timestamp > DPOP_NONCE_VALIDITY_SECS { + return Err(OAuthError::UseDpopNonce(self.generate_nonce())); + } + + let mut hasher = Sha256::new(); + hasher.update(&self.secret); + hasher.update(×tamp_bytes); + let expected_hash = hasher.finalize(); + + if nonce_bytes[8..24] != expected_hash[..16] { + return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string())); + } + + Ok(()) + } + + pub fn verify_proof( + &self, + dpop_header: &str, + http_method: &str, + http_uri: &str, + access_token_hash: Option<&str>, + ) -> Result { + let parts: Vec<&str> = dpop_header.split('.').collect(); + if parts.len() != 3 { + return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string())); + } + + let header_json = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid header encoding".to_string()))?; + let payload_json = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload encoding".to_string()))?; + + let header: DPoPProofHeader = serde_json::from_slice(&header_json) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid header JSON".to_string()))?; + let payload: DPoPProofPayload = serde_json::from_slice(&payload_json) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?; + + if header.typ != "dpop+jwt" { + return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string())); + } + + if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") { + return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string())); + } + + if payload.htm.to_uppercase() != http_method.to_uppercase() { + return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string())); + } + + let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu); + let request_uri = http_uri.split('?').next().unwrap_or(http_uri); + if proof_uri != request_uri { + return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string())); + } + + let now = Utc::now().timestamp(); + if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS { + return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string())); + } + + if let Some(nonce) = &payload.nonce { + self.validate_nonce(nonce)?; + } + + if let Some(expected_ath) = access_token_hash { + match &payload.ath { + Some(ath) if ath == expected_ath => {} + Some(_) => { + return Err(OAuthError::InvalidDpopProof( + "Access token hash mismatch".to_string(), + )); + } + None => { + return Err(OAuthError::InvalidDpopProof( + "Missing access token hash".to_string(), + )); + } + } + } + + let signature_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?; + + let signing_input = format!("{}.{}", parts[0], parts[1]); + verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?; + + let jkt = compute_jwk_thumbprint(&header.jwk)?; + + Ok(DPoPVerifyResult { + jkt, + jti: payload.jti.clone(), + }) + } +} + +fn verify_dpop_signature( + alg: &str, + jwk: &DPoPJwk, + message: &[u8], + signature: &[u8], +) -> Result<(), OAuthError> { + match alg { + "ES256" => verify_es256(jwk, message, signature), + "ES384" => verify_es384(jwk, message, signature), + "EdDSA" => verify_eddsa(jwk, message, signature), + _ => Err(OAuthError::InvalidDpopProof(format!( + "Unsupported algorithm: {}", + alg + ))), + } +} + +fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { + use p256::ecdsa::signature::Verifier; + use p256::ecdsa::{Signature, VerifyingKey}; + use p256::elliptic_curve::sec1::FromEncodedPoint; + use p256::{AffinePoint, EncodedPoint}; + + let crv = jwk.crv.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()) + })?; + if crv != "P-256" { + return Err(OAuthError::InvalidDpopProof(format!( + "Invalid curve for ES256: {}", + crv + ))); + } + + let x_bytes = URL_SAFE_NO_PAD + .decode(jwk.x.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) + })?) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; + + let y_bytes = URL_SAFE_NO_PAD + .decode(jwk.y.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) + })?) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; + + let point = EncodedPoint::from_affine_coordinates( + x_bytes.as_slice().into(), + y_bytes.as_slice().into(), + false, + ); + + let affine = AffinePoint::from_encoded_point(&point); + if affine.is_none().into() { + return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); + } + + let verifying_key = VerifyingKey::from_affine(affine.unwrap()) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; + + let sig = Signature::from_slice(signature) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?; + + verifying_key + .verify(message, &sig) + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) +} + +fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { + use p384::ecdsa::signature::Verifier; + use p384::ecdsa::{Signature, VerifyingKey}; + use p384::elliptic_curve::sec1::FromEncodedPoint; + use p384::{AffinePoint, EncodedPoint}; + + let crv = jwk.crv.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()) + })?; + if crv != "P-384" { + return Err(OAuthError::InvalidDpopProof(format!( + "Invalid curve for ES384: {}", + crv + ))); + } + + let x_bytes = URL_SAFE_NO_PAD + .decode(jwk.x.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) + })?) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; + + let y_bytes = URL_SAFE_NO_PAD + .decode(jwk.y.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) + })?) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; + + let point = EncodedPoint::from_affine_coordinates( + x_bytes.as_slice().into(), + y_bytes.as_slice().into(), + false, + ); + + let affine = AffinePoint::from_encoded_point(&point); + if affine.is_none().into() { + return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); + } + + let verifying_key = VerifyingKey::from_affine(affine.unwrap()) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; + + let sig = Signature::from_slice(signature) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?; + + verifying_key + .verify(message, &sig) + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) +} + +fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { + use ed25519_dalek::{Signature, VerifyingKey}; + + let crv = jwk.crv.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()) + })?; + if crv != "Ed25519" { + return Err(OAuthError::InvalidDpopProof(format!( + "Invalid curve for EdDSA: {}", + crv + ))); + } + + let x_bytes = URL_SAFE_NO_PAD + .decode(jwk.x.as_ref().ok_or_else(|| { + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) + })?) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; + + let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| { + OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()) + })?; + + let verifying_key = VerifyingKey::from_bytes(&key_bytes) + .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?; + + let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| { + OAuthError::InvalidDpopProof("Invalid Ed25519 signature length".to_string()) + })?; + let sig = Signature::from_bytes(&sig_bytes); + + verifying_key + .verify_strict(message, &sig) + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) +} + +pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result { + let canonical = match jwk.kty.as_str() { + "EC" => { + let crv = jwk + .crv + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?; + let x = jwk + .x + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?; + let y = jwk + .y + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?; + + format!( + r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, + crv, x, y + ) + } + "OKP" => { + let crv = jwk + .crv + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?; + let x = jwk + .x + .as_ref() + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?; + + format!(r#"{{"crv":"{}","kty":"OKP","x":"{}"}}"#, crv, x) + } + _ => { + return Err(OAuthError::InvalidDpopProof( + "Unsupported key type".to_string(), + )); + } + }; + + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let hash = hasher.finalize(); + + Ok(URL_SAFE_NO_PAD.encode(&hash)) +} + +pub fn compute_access_token_hash(access_token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(access_token.as_bytes()); + let hash = hasher.finalize(); + URL_SAFE_NO_PAD.encode(&hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nonce_generation_and_validation() { + let secret = b"test-secret-key-32-bytes-long!!!"; + let verifier = DPoPVerifier::new(secret); + + let nonce = verifier.generate_nonce(); + assert!(verifier.validate_nonce(&nonce).is_ok()); + } + + #[test] + fn test_jwk_thumbprint_ec() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: Some("test_x".to_string()), + y: Some("test_y".to_string()), + }; + + let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); + assert!(!thumbprint.is_empty()); + } +} diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs new file mode 100644 index 0000000..af5b38a --- /dev/null +++ b/src/oauth/endpoints/authorize.rs @@ -0,0 +1,210 @@ +use axum::{ + Form, Json, + extract::{Query, State}, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use urlencoding::encode as url_encode; + +use crate::state::AppState; +use crate::oauth::{Code, DeviceData, DeviceId, OAuthError, SessionId, db}; + +fn extract_client_ip(headers: &HeaderMap) -> String { + if let Some(forwarded) = headers.get("x-forwarded-for") { + if let Ok(value) = forwarded.to_str() { + if let Some(first_ip) = value.split(',').next() { + return first_ip.trim().to_string(); + } + } + } + + if let Some(real_ip) = headers.get("x-real-ip") { + if let Ok(value) = real_ip.to_str() { + return value.trim().to_string(); + } + } + + "0.0.0.0".to_string() +} + +fn extract_user_agent(headers: &HeaderMap) -> Option { + headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeQuery { + pub request_uri: Option, + pub client_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct AuthorizeResponse { + pub client_id: String, + pub client_name: Option, + pub scope: Option, + pub redirect_uri: String, + pub state: Option, + pub login_hint: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeSubmit { + pub request_uri: String, + pub username: String, + pub password: String, + #[serde(default)] + pub remember_device: bool, +} + +pub async fn authorize_get( + State(state): State, + Query(query): Query, +) -> Result, OAuthError> { + let request_uri = query.request_uri.ok_or_else(|| { + OAuthError::InvalidRequest("request_uri is required".to_string()) + })?; + + let request_data = db::get_authorization_request(&state.db, &request_uri) + .await? + .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; + + if request_data.expires_at < Utc::now() { + db::delete_authorization_request(&state.db, &request_uri).await?; + return Err(OAuthError::InvalidRequest("request_uri has expired".to_string())); + } + + Ok(Json(AuthorizeResponse { + client_id: request_data.parameters.client_id.clone(), + client_name: None, + scope: request_data.parameters.scope.clone(), + redirect_uri: request_data.parameters.redirect_uri.clone(), + state: request_data.parameters.state.clone(), + login_hint: request_data.parameters.login_hint.clone(), + })) +} + +pub async fn authorize_post( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Result { + let request_data = db::get_authorization_request(&state.db, &form.request_uri) + .await? + .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; + + if request_data.expires_at < Utc::now() { + db::delete_authorization_request(&state.db, &form.request_uri).await?; + return Err(OAuthError::InvalidRequest("request_uri has expired".to_string())); + } + + let user = sqlx::query!( + r#" + SELECT did, password_hash, deactivated_at, takedown_ref + FROM users + WHERE handle = $1 OR email = $1 + "#, + form.username + ) + .fetch_optional(&state.db) + .await + .map_err(|e| OAuthError::ServerError(e.to_string()))? + .ok_or_else(|| OAuthError::AccessDenied("Invalid credentials".to_string()))?; + + if user.deactivated_at.is_some() { + return Err(OAuthError::AccessDenied("Account is deactivated".to_string())); + } + + if user.takedown_ref.is_some() { + return Err(OAuthError::AccessDenied("Account is taken down".to_string())); + } + + let password_valid = bcrypt::verify(&form.password, &user.password_hash) + .map_err(|_| OAuthError::ServerError("Password verification failed".to_string()))?; + + if !password_valid { + return Err(OAuthError::AccessDenied("Invalid credentials".to_string())); + } + + let code = Code::generate(); + let mut device_id: Option = None; + + if form.remember_device { + let new_device_id = DeviceId::generate(); + let device_data = DeviceData { + session_id: SessionId::generate().0, + user_agent: extract_user_agent(&headers), + ip_address: extract_client_ip(&headers), + last_seen_at: Utc::now(), + }; + + db::create_device(&state.db, &new_device_id.0, &device_data).await?; + db::upsert_account_device(&state.db, &user.did, &new_device_id.0).await?; + device_id = Some(new_device_id.0); + } + + db::update_authorization_request( + &state.db, + &form.request_uri, + &user.did, + device_id.as_deref(), + &code.0, + ) + .await?; + + let redirect_uri = &request_data.parameters.redirect_uri; + let mut redirect_url = redirect_uri.to_string(); + + let separator = if redirect_url.contains('?') { '&' } else { '?' }; + redirect_url.push(separator); + redirect_url.push_str(&format!("code={}", url_encode(&code.0))); + + if let Some(state) = &request_data.parameters.state { + redirect_url.push_str(&format!("&state={}", url_encode(state))); + } + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname)))); + + Ok(Redirect::temporary(&redirect_url).into_response()) +} + +#[derive(Debug, Serialize)] +pub struct AuthorizeDenyResponse { + pub error: String, + pub error_description: String, +} + +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?; + + let redirect_uri = &request_data.parameters.redirect_uri; + let mut redirect_url = redirect_uri.to_string(); + + let separator = if redirect_url.contains('?') { '&' } else { '?' }; + redirect_url.push(separator); + redirect_url.push_str("error=access_denied"); + redirect_url.push_str("&error_description=User%20denied%20the%20request"); + + if let Some(state) = &request_data.parameters.state { + redirect_url.push_str(&format!("&state={}", url_encode(state))); + } + + Ok(Redirect::temporary(&redirect_url).into_response()) +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeDenyForm { + pub request_uri: String, +} diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs new file mode 100644 index 0000000..0d49aeb --- /dev/null +++ b/src/oauth/endpoints/metadata.rs @@ -0,0 +1,124 @@ +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; +use crate::oauth::jwks::{JwkSet, create_jwk_set}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Vec, + pub bearer_methods_supported: Vec, + pub scopes_supported: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_documentation: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthorizationServerMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub jwks_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes_supported: Option>, + pub response_types_supported: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_modes_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_types_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_methods_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_challenge_methods_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pushed_authorization_request_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub require_pushed_authorization_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dpop_signing_alg_values_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_response_iss_parameter_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revocation_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub introspection_endpoint: Option, +} + +pub async fn oauth_protected_resource( + State(_state): State, +) -> Json { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let public_url = format!("https://{}", pds_hostname); + + Json(ProtectedResourceMetadata { + resource: public_url.clone(), + authorization_servers: vec![public_url], + bearer_methods_supported: vec!["header".to_string()], + scopes_supported: vec![], + resource_documentation: Some("https://atproto.com".to_string()), + }) +} + +pub async fn oauth_authorization_server( + State(_state): State, +) -> Json { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let issuer = format!("https://{}", pds_hostname); + + Json(AuthorizationServerMetadata { + issuer: issuer.clone(), + authorization_endpoint: format!("{}/oauth/authorize", issuer), + token_endpoint: format!("{}/oauth/token", issuer), + jwks_uri: format!("{}/oauth/jwks", issuer), + registration_endpoint: None, + scopes_supported: Some(vec![ + "atproto".to_string(), + "transition:generic".to_string(), + "transition:chat.bsky".to_string(), + ]), + response_types_supported: vec!["code".to_string()], + response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]), + grant_types_supported: Some(vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ]), + token_endpoint_auth_methods_supported: Some(vec![ + "none".to_string(), + "private_key_jwt".to_string(), + ]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + pushed_authorization_request_endpoint: Some(format!("{}/oauth/par", issuer)), + require_pushed_authorization_requests: Some(true), + dpop_signing_alg_values_supported: Some(vec![ + "ES256".to_string(), + "ES384".to_string(), + "ES512".to_string(), + "EdDSA".to_string(), + ]), + authorization_response_iss_parameter_supported: Some(true), + revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)), + introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)), + }) +} + +pub async fn oauth_jwks(State(_state): State) -> Json { + use crate::config::AuthConfig; + use crate::oauth::jwks::Jwk; + + let config = AuthConfig::get(); + + let server_key = Jwk { + kty: "EC".to_string(), + key_use: Some("sig".to_string()), + kid: Some(config.signing_key_id.clone()), + alg: Some("ES256".to_string()), + crv: Some("P-256".to_string()), + x: Some(config.signing_key_x.clone()), + y: Some(config.signing_key_y.clone()), + }; + + Json(create_jwk_set(vec![server_key])) +} diff --git a/src/oauth/endpoints/mod.rs b/src/oauth/endpoints/mod.rs new file mode 100644 index 0000000..97bc947 --- /dev/null +++ b/src/oauth/endpoints/mod.rs @@ -0,0 +1,9 @@ +pub mod metadata; +pub mod par; +pub mod authorize; +pub mod token; + +pub use metadata::*; +pub use par::*; +pub use authorize::*; +pub use token::*; diff --git a/src/oauth/endpoints/par.rs b/src/oauth/endpoints/par.rs new file mode 100644 index 0000000..d865cf4 --- /dev/null +++ b/src/oauth/endpoints/par.rs @@ -0,0 +1,192 @@ +use axum::{ + Form, Json, + extract::State, +}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; +use crate::oauth::{ + AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId, + client::ClientMetadataCache, + db, +}; + +const PAR_EXPIRY_SECONDS: i64 = 600; + +const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"]; + +#[derive(Debug, Deserialize)] +pub struct ParRequest { + pub response_type: String, + pub client_id: String, + pub redirect_uri: String, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub state: Option, + #[serde(default)] + pub code_challenge: Option, + #[serde(default)] + pub code_challenge_method: Option, + #[serde(default)] + pub login_hint: Option, + #[serde(default)] + pub dpop_jkt: Option, + #[serde(default)] + pub client_secret: Option, + #[serde(default)] + pub client_assertion: Option, + #[serde(default)] + pub client_assertion_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct ParResponse { + pub request_uri: String, + pub expires_in: u64, +} + +pub async fn pushed_authorization_request( + State(state): State, + Form(request): Form, +) -> Result, OAuthError> { + if request.response_type != "code" { + return Err(OAuthError::InvalidRequest( + "response_type must be 'code'".to_string(), + )); + } + + let code_challenge = request.code_challenge.as_ref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| OAuthError::InvalidRequest( + "code_challenge is required".to_string(), + ))?; + + let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or(""); + if code_challenge_method != "S256" { + return Err(OAuthError::InvalidRequest( + "code_challenge_method must be 'S256'".to_string(), + )); + } + + let client_cache = ClientMetadataCache::new(3600); + let client_metadata = client_cache.get(&request.client_id).await?; + + client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?; + + let client_auth = determine_client_auth(&request)?; + + if client_metadata.requires_dpop() && request.dpop_jkt.is_none() { + return Err(OAuthError::InvalidRequest( + "dpop_jkt is required for this client".to_string(), + )); + } + + 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 parameters = AuthorizationRequestParameters { + response_type: request.response_type, + client_id: request.client_id.clone(), + redirect_uri: request.redirect_uri, + scope: validated_scope, + state: request.state, + code_challenge: code_challenge.clone(), + code_challenge_method: code_challenge_method.to_string(), + login_hint: request.login_hint, + dpop_jkt: request.dpop_jkt, + extra: None, + }; + + let request_data = RequestData { + client_id: request.client_id, + client_auth: Some(client_auth), + parameters, + expires_at, + did: None, + device_id: None, + code: None, + }; + + db::create_authorization_request(&state.db, &request_id.0, &request_data).await?; + + tokio::spawn({ + let pool = state.db.clone(); + async move { + if let Err(e) = db::delete_expired_authorization_requests(&pool).await { + tracing::warn!("Failed to cleanup expired authorization requests: {:?}", e); + } + } + }); + + Ok(Json(ParResponse { + request_uri: request_id.0, + expires_in: PAR_EXPIRY_SECONDS as u64, + })) +} + +fn determine_client_auth(request: &ParRequest) -> Result { + if let (Some(assertion), Some(assertion_type)) = + (&request.client_assertion, &request.client_assertion_type) + { + if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { + return Err(OAuthError::InvalidRequest( + "Unsupported client_assertion_type".to_string(), + )); + } + return Ok(ClientAuth::PrivateKeyJwt { + client_assertion: assertion.clone(), + }); + } + + if let Some(secret) = &request.client_secret { + return Ok(ClientAuth::SecretPost { + client_secret: secret.clone(), + }); + } + + Ok(ClientAuth::None) +} + +fn validate_scope( + requested_scope: &Option, + client_metadata: &crate::oauth::client::ClientMetadata, +) -> Result, OAuthError> { + let scope_str = match requested_scope { + Some(s) if !s.is_empty() => s, + _ => return Ok(Some("atproto".to_string())), + }; + + let requested_scopes: Vec<&str> = scope_str.split_whitespace().collect(); + + if requested_scopes.is_empty() { + return Ok(Some("atproto".to_string())); + } + + for scope in &requested_scopes { + if !SUPPORTED_SCOPES.contains(scope) { + return Err(OAuthError::InvalidScope(format!( + "Unsupported scope: {}. Supported scopes: {}", + scope, + SUPPORTED_SCOPES.join(", ") + ))); + } + } + + 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) { + return Err(OAuthError::InvalidScope(format!( + "Scope '{}' not registered for this client", + scope + ))); + } + } + } + + Ok(Some(requested_scopes.join(" "))) +} diff --git a/src/oauth/endpoints/token.rs b/src/oauth/endpoints/token.rs new file mode 100644 index 0000000..6201f87 --- /dev/null +++ b/src/oauth/endpoints/token.rs @@ -0,0 +1,558 @@ +use axum::{ + Form, Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use hmac::Mac; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::config::AuthConfig; +use crate::state::AppState; +use crate::oauth::{ + ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, + client::{ClientMetadataCache, verify_client_auth}, + db, + dpop::DPoPVerifier, +}; + +const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; +const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; + +#[derive(Debug, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub redirect_uri: Option, + #[serde(default)] + pub code_verifier: Option, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret: Option, + #[serde(default)] + pub client_assertion: Option, + #[serde(default)] + pub client_assertion_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, +} + +pub async fn token_endpoint( + State(state): State, + headers: HeaderMap, + Form(request): Form, +) -> Result<(HeaderMap, Json), OAuthError> { + let dpop_proof = headers + .get("DPoP") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match request.grant_type.as_str() { + "authorization_code" => { + handle_authorization_code_grant(state, headers, request, dpop_proof).await + } + "refresh_token" => { + handle_refresh_token_grant(state, headers, request, dpop_proof).await + } + _ => Err(OAuthError::UnsupportedGrantType(format!( + "Unsupported grant_type: {}", + request.grant_type + ))), + } +} + +async fn handle_authorization_code_grant( + state: AppState, + _headers: HeaderMap, + request: TokenRequest, + dpop_proof: Option, +) -> Result<(HeaderMap, Json), OAuthError> { + let code = request + .code + .ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?; + + let code_verifier = request + .code_verifier + .ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?; + + let auth_request = db::consume_authorization_request_by_code(&state.db, &code) + .await? + .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; + + if auth_request.expires_at < Utc::now() { + return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string())); + } + + if let Some(request_client_id) = &request.client_id { + if 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()))?; + + let client_metadata_cache = ClientMetadataCache::new(3600); + let client_metadata = client_metadata_cache + .get(&auth_request.client_id) + .await?; + let client_auth = auth_request.client_auth.clone().unwrap_or(ClientAuth::None); + verify_client_auth(&client_metadata, &client_auth)?; + + verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; + + if let Some(redirect_uri) = &request.redirect_uri { + if 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()); + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); + + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { + return Err(OAuthError::InvalidDpopProof( + "DPoP proof has already been used".to_string(), + )); + } + + if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt { + if &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( + "DPoP proof required for this authorization".to_string(), + )); + } else { + None + }; + + 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 token_data = TokenData { + did: did.clone(), + token_id: token_id.0.clone(), + created_at: now, + updated_at: now, + expires_at: now + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS), + client_id: auth_request.client_id.clone(), + client_auth: auth_request.client_auth.unwrap_or(ClientAuth::None), + device_id: auth_request.device_id, + parameters: auth_request.parameters.clone(), + details: None, + code: None, + current_refresh_token: Some(refresh_token.0.clone()), + scope: auth_request.parameters.scope.clone(), + }; + + db::create_token(&state.db, &token_data).await?; + + tokio::spawn({ + let pool = state.db.clone(); + let did_clone = did.clone(); + async move { + if let Err(e) = db::enforce_token_limit_for_user(&pool, &did_clone).await { + tracing::warn!("Failed to enforce token limit for user: {:?}", e); + } + } + }); + + let mut response_headers = HeaderMap::new(); + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + response_headers.insert( + "DPoP-Nonce", + verifier.generate_nonce().parse().unwrap(), + ); + + Ok(( + response_headers, + Json(TokenResponse { + access_token, + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + refresh_token: Some(refresh_token.0), + scope: auth_request.parameters.scope, + sub: Some(did), + }), + )) +} + +async fn handle_refresh_token_grant( + state: AppState, + _headers: HeaderMap, + request: TokenRequest, + dpop_proof: Option, +) -> Result<(HeaderMap, Json), OAuthError> { + let refresh_token_str = request + .refresh_token + .ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?; + + if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { + db::delete_token_family(&state.db, token_id).await?; + return Err(OAuthError::InvalidGrant( + "Refresh token reuse detected, token family revoked".to_string(), + )); + } + + let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str) + .await? + .ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?; + + if token_data.expires_at < Utc::now() { + db::delete_token_family(&state.db, db_id).await?; + return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string())); + } + + let dpop_jkt = if let Some(proof) = &dpop_proof { + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); + + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { + return Err(OAuthError::InvalidDpopProof( + "DPoP proof has already been used".to_string(), + )); + } + + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { + if &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( + "DPoP proof required".to_string(), + )); + } else { + None + }; + + let new_token_id = TokenId::generate(); + let new_refresh_token = RefreshToken::generate(); + let new_expires_at = Utc::now() + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS); + + db::rotate_token( + &state.db, + db_id, + &new_token_id.0, + &new_refresh_token.0, + new_expires_at, + ) + .await?; + + let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?; + + let mut response_headers = HeaderMap::new(); + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + response_headers.insert( + "DPoP-Nonce", + verifier.generate_nonce().parse().unwrap(), + ); + + Ok(( + response_headers, + Json(TokenResponse { + access_token, + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + refresh_token: Some(new_refresh_token.0), + scope: token_data.scope, + sub: Some(token_data.did), + }), + )) +} + +fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> { + use subtle::ConstantTimeEq; + + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let computed_challenge = URL_SAFE_NO_PAD.encode(&hash); + + if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) { + return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string())); + } + + Ok(()) +} + +fn create_access_token( + token_id: &str, + sub: &str, + dpop_jkt: 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 mut payload = json!({ + "iss": issuer, + "sub": sub, + "aud": issuer, + "iat": now, + "exp": exp, + "jti": token_id, + "scope": "atproto" + }); + + if let Some(jkt) = dpop_jkt { + payload["cnf"] = json!({ "jkt": jkt }); + } + + let header = json!({ + "alg": "HS256", + "typ": "at+jwt" + }); + + 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 config = AuthConfig::get(); + + use sha2::Sha256 as HmacSha256; + use hmac::{Hmac, Mac}; + type HmacSha256Type = Hmac; + + let mut mac = HmacSha256Type::new_from_slice(config.jwt_secret().as_bytes()) + .map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?; + mac.update(signing_input.as_bytes()); + let signature = mac.finalize().into_bytes(); + + let signature_b64 = URL_SAFE_NO_PAD.encode(&signature); + + Ok(format!("{}.{}", signing_input, signature_b64)) +} + +pub async fn revoke_token( + State(state): State, + Form(request): Form, +) -> Result { + if let Some(token) = &request.token { + if let Some((db_id, _)) = db::get_token_by_refresh_token(&state.db, token).await? { + db::delete_token_family(&state.db, db_id).await?; + } else { + db::delete_token(&state.db, token).await?; + } + } + + Ok(StatusCode::OK) +} + +#[derive(Debug, Deserialize)] +pub struct RevokeRequest { + pub token: Option, + #[serde(default)] + pub token_type_hint: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IntrospectRequest { + pub token: String, + #[serde(default)] + pub token_type_hint: Option, +} + +#[derive(Debug, Serialize)] +pub struct IntrospectResponse { + pub active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, +} + +pub async fn introspect_token( + State(state): State, + Form(request): Form, +) -> Json { + let inactive_response = IntrospectResponse { + active: false, + scope: None, + client_id: None, + username: None, + token_type: None, + exp: None, + iat: None, + nbf: None, + sub: None, + aud: None, + iss: None, + jti: None, + }; + + let token_info = match extract_token_claims(&request.token) { + Ok(info) => info, + Err(_) => return Json(inactive_response), + }; + + let token_data = match db::get_token_by_id(&state.db, &token_info.jti).await { + Ok(Some(data)) => data, + _ => return Json(inactive_response), + }; + + if token_data.expires_at < Utc::now() { + return Json(inactive_response); + } + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let issuer = format!("https://{}", pds_hostname); + + Json(IntrospectResponse { + active: true, + scope: token_data.scope, + client_id: Some(token_data.client_id), + username: None, + token_type: if token_data.parameters.dpop_jkt.is_some() { + Some("DPoP".to_string()) + } else { + Some("Bearer".to_string()) + }, + exp: Some(token_info.exp), + iat: Some(token_info.iat), + nbf: Some(token_info.iat), + sub: Some(token_data.did), + aud: Some(issuer.clone()), + iss: Some(issuer), + jti: Some(token_info.jti), + }) +} + +struct TokenClaims { + jti: String, + exp: i64, + iat: i64, +} + +fn extract_token_claims(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(OAuthError::InvalidToken("Invalid token format".to_string())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; + + if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { + return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); + } + if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { + return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); + } + + let config = AuthConfig::get(); + let secret = config.jwt_secret(); + + let signing_input = format!("{}.{}", parts[0], parts[1]); + let provided_sig = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; + + type HmacSha256 = hmac::Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; + mac.update(signing_input.as_bytes()); + let expected_sig = mac.finalize().into_bytes(); + + if !bool::from(expected_sig.ct_eq(&provided_sig)) { + return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; + + let jti = payload + .get("jti") + .and_then(|j| j.as_str()) + .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? + .to_string(); + + let exp = payload + .get("exp") + .and_then(|e| e.as_i64()) + .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; + + let iat = payload + .get("iat") + .and_then(|i| i.as_i64()) + .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; + + Ok(TokenClaims { jti, exp, iat }) +} diff --git a/src/oauth/error.rs b/src/oauth/error.rs new file mode 100644 index 0000000..dd5fd9a --- /dev/null +++ b/src/oauth/error.rs @@ -0,0 +1,102 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +#[derive(Debug)] +pub enum OAuthError { + InvalidRequest(String), + InvalidClient(String), + InvalidGrant(String), + UnauthorizedClient(String), + UnsupportedGrantType(String), + InvalidScope(String), + AccessDenied(String), + ServerError(String), + UseDpopNonce(String), + InvalidDpopProof(String), + ExpiredToken(String), + InvalidToken(String), +} + +#[derive(Serialize)] +struct OAuthErrorResponse { + error: String, + error_description: Option, +} + +impl IntoResponse for OAuthError { + fn into_response(self) -> Response { + let (status, error, description) = match self { + OAuthError::InvalidRequest(msg) => { + (StatusCode::BAD_REQUEST, "invalid_request", Some(msg)) + } + OAuthError::InvalidClient(msg) => { + (StatusCode::UNAUTHORIZED, "invalid_client", Some(msg)) + } + OAuthError::InvalidGrant(msg) => { + (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)) + } + OAuthError::UnauthorizedClient(msg) => { + (StatusCode::UNAUTHORIZED, "unauthorized_client", Some(msg)) + } + OAuthError::UnsupportedGrantType(msg) => { + (StatusCode::BAD_REQUEST, "unsupported_grant_type", Some(msg)) + } + OAuthError::InvalidScope(msg) => { + (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)) + } + OAuthError::AccessDenied(msg) => { + (StatusCode::FORBIDDEN, "access_denied", Some(msg)) + } + OAuthError::ServerError(msg) => { + (StatusCode::INTERNAL_SERVER_ERROR, "server_error", Some(msg)) + } + OAuthError::UseDpopNonce(nonce) => { + return ( + StatusCode::BAD_REQUEST, + [("DPoP-Nonce", nonce)], + Json(OAuthErrorResponse { + error: "use_dpop_nonce".to_string(), + error_description: Some("A DPoP nonce is required".to_string()), + }), + ) + .into_response(); + } + OAuthError::InvalidDpopProof(msg) => { + (StatusCode::UNAUTHORIZED, "invalid_dpop_proof", Some(msg)) + } + OAuthError::ExpiredToken(msg) => { + (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) + } + OAuthError::InvalidToken(msg) => { + (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) + } + }; + + ( + status, + Json(OAuthErrorResponse { + error: error.to_string(), + error_description: description, + }), + ) + .into_response() + } +} + +impl From for OAuthError { + fn from(err: sqlx::Error) -> Self { + tracing::error!("Database error in OAuth flow: {}", err); + OAuthError::ServerError("An internal error occurred".to_string()) + } +} + +impl From for OAuthError { + fn from(err: anyhow::Error) -> Self { + tracing::error!("Internal error in OAuth flow: {}", err); + OAuthError::ServerError("An internal error occurred".to_string()) + } +} diff --git a/src/oauth/jwks.rs b/src/oauth/jwks.rs new file mode 100644 index 0000000..a4b3d91 --- /dev/null +++ b/src/oauth/jwks.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwkSet { + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Jwk { + pub kty: String, + #[serde(rename = "use", skip_serializing_if = "Option::is_none")] + pub key_use: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub crv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub y: Option, +} + +pub fn create_jwk_set(keys: Vec) -> JwkSet { + JwkSet { keys } +} diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs new file mode 100644 index 0000000..6f2912a --- /dev/null +++ b/src/oauth/mod.rs @@ -0,0 +1,12 @@ +pub mod types; +pub mod db; +pub mod dpop; +pub mod jwks; +pub mod client; +pub mod endpoints; +pub mod error; +pub mod verify; + +pub use types::*; +pub use error::OAuthError; +pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError}; diff --git a/src/oauth/types.rs b/src/oauth/types.rs new file mode 100644 index 0000000..e733b60 --- /dev/null +++ b/src/oauth/types.rs @@ -0,0 +1,241 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Code(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshToken(pub String); + +impl RequestId { + pub fn generate() -> Self { + Self(format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4())) + } +} + +impl TokenId { + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } +} + +impl DeviceId { + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } +} + +impl SessionId { + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } +} + +impl Code { + pub fn generate() -> Self { + use rand::Rng; + let bytes: [u8; 32] = rand::thread_rng().r#gen(); + Self(base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + bytes, + )) + } +} + +impl RefreshToken { + pub fn generate() -> Self { + use rand::Rng; + let bytes: [u8; 32] = rand::thread_rng().r#gen(); + Self(base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + bytes, + )) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method")] +pub enum ClientAuth { + #[serde(rename = "none")] + None, + #[serde(rename = "client_secret_basic")] + SecretBasic { client_secret: String }, + #[serde(rename = "client_secret_post")] + SecretPost { client_secret: String }, + #[serde(rename = "private_key_jwt")] + PrivateKeyJwt { client_assertion: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationRequestParameters { + pub response_type: String, + pub client_id: String, + pub redirect_uri: String, + pub scope: Option, + pub state: Option, + pub code_challenge: String, + pub code_challenge_method: String, + pub login_hint: Option, + pub dpop_jkt: Option, + #[serde(flatten)] + pub extra: Option, +} + +#[derive(Debug, Clone)] +pub struct RequestData { + pub client_id: String, + pub client_auth: Option, + pub parameters: AuthorizationRequestParameters, + pub expires_at: DateTime, + pub did: Option, + pub device_id: Option, + pub code: Option, +} + +#[derive(Debug, Clone)] +pub struct DeviceData { + pub session_id: String, + pub user_agent: Option, + pub ip_address: String, + pub last_seen_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct TokenData { + pub did: String, + pub token_id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub expires_at: DateTime, + pub client_id: String, + pub client_auth: ClientAuth, + pub device_id: Option, + pub parameters: AuthorizationRequestParameters, + pub details: Option, + pub code: Option, + pub current_refresh_token: Option, + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizedClientData { + pub scope: Option, + pub remember: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthClientMetadata { + pub client_id: String, + pub client_name: Option, + pub client_uri: Option, + pub logo_uri: Option, + pub redirect_uris: Vec, + pub grant_types: Option>, + pub response_types: Option>, + pub scope: Option, + pub token_endpoint_auth_method: Option, + pub dpop_bound_access_tokens: Option, + pub jwks: Option, + pub jwks_uri: Option, + pub application_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Vec, + pub bearer_methods_supported: Vec, + pub scopes_supported: Vec, + pub resource_documentation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationServerMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub jwks_uri: String, + pub registration_endpoint: Option, + pub scopes_supported: Option>, + pub response_types_supported: Vec, + pub response_modes_supported: Option>, + pub grant_types_supported: Option>, + pub token_endpoint_auth_methods_supported: Option>, + pub code_challenge_methods_supported: Option>, + pub pushed_authorization_request_endpoint: Option, + pub require_pushed_authorization_requests: Option, + pub dpop_signing_alg_values_supported: Option>, + pub authorization_response_iss_parameter_supported: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParResponse { + pub request_uri: String, + pub expires_in: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: Option, + pub redirect_uri: Option, + pub code_verifier: Option, + pub refresh_token: Option, + pub client_id: Option, + pub client_secret: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DPoPClaims { + pub jti: String, + pub htm: String, + pub htu: String, + pub iat: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub ath: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwkPublicKey { + pub kty: String, + pub crv: Option, + pub x: Option, + pub y: Option, + #[serde(rename = "use")] + pub key_use: Option, + pub kid: Option, + pub alg: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Jwks { + pub keys: Vec, +} diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs new file mode 100644 index 0000000..d9c4a8b --- /dev/null +++ b/src/oauth/verify.rs @@ -0,0 +1,312 @@ +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, + Json, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac}; +use serde_json::json; +use sha2::Sha256; +use sqlx::PgPool; +use subtle::ConstantTimeEq; + +use crate::config::AuthConfig; +use crate::state::AppState; +use super::db; +use super::dpop::DPoPVerifier; +use super::OAuthError; + +pub struct OAuthTokenInfo { + pub did: String, + pub token_id: String, + pub client_id: String, + pub scope: Option, + pub dpop_jkt: Option, +} + +pub struct VerifyResult { + pub did: String, + pub token_id: String, + pub client_id: String, + pub scope: Option, +} + +pub async fn verify_oauth_access_token( + pool: &PgPool, + access_token: &str, + dpop_proof: Option<&str>, + http_method: &str, + http_uri: &str, +) -> Result { + let token_info = extract_oauth_token_info(access_token)?; + + let token_data = db::get_token_by_id(pool, &token_info.token_id) + .await? + .ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?; + + let now = chrono::Utc::now(); + if token_data.expires_at < now { + return Err(OAuthError::InvalidToken("Token has expired".to_string())); + } + + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { + let proof = dpop_proof.ok_or_else(|| { + OAuthError::UseDpopNonce("DPoP proof required".to_string()) + })?; + + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + + let access_token_hash = compute_ath(access_token); + let result = verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; + + if !db::check_and_record_dpop_jti(pool, &result.jti).await? { + return Err(OAuthError::InvalidDpopProof( + "DPoP proof has already been used".to_string(), + )); + } + + if &result.jkt != expected_jkt { + return Err(OAuthError::InvalidDpopProof( + "DPoP key binding mismatch".to_string(), + )); + } + } + + Ok(VerifyResult { + did: token_data.did, + token_id: token_info.token_id, + client_id: token_data.client_id, + scope: token_data.scope, + }) +} + +pub fn extract_oauth_token_info(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(OAuthError::InvalidToken("Invalid token format".to_string())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; + + if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { + return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); + } + if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { + return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); + } + + let config = AuthConfig::get(); + let secret = config.jwt_secret(); + + let signing_input = format!("{}.{}", parts[0], parts[1]); + let provided_sig = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; + + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; + mac.update(signing_input.as_bytes()); + let expected_sig = mac.finalize().into_bytes(); + + if !bool::from(expected_sig.ct_eq(&provided_sig)) { + return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; + + let exp = payload + .get("exp") + .and_then(|e| e.as_i64()) + .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; + let now = chrono::Utc::now().timestamp(); + if exp < now { + return Err(OAuthError::InvalidToken("Token has expired".to_string())); + } + + let token_id = payload + .get("jti") + .and_then(|j| j.as_str()) + .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? + .to_string(); + + let did = payload + .get("sub") + .and_then(|s| s.as_str()) + .ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))? + .to_string(); + + let scope = payload.get("scope").and_then(|s| s.as_str()).map(|s| s.to_string()); + + let dpop_jkt = payload + .get("cnf") + .and_then(|c| c.get("jkt")) + .and_then(|j| j.as_str()) + .map(|s| s.to_string()); + + let client_id = payload + .get("client_id") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + Ok(OAuthTokenInfo { + did, + token_id, + client_id, + scope, + dpop_jkt, + }) +} + +fn compute_ath(access_token: &str) -> String { + use sha2::Digest; + let mut hasher = Sha256::new(); + hasher.update(access_token.as_bytes()); + let hash = hasher.finalize(); + URL_SAFE_NO_PAD.encode(&hash) +} + +pub fn generate_dpop_nonce() -> String { + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + verifier.generate_nonce() +} + +pub struct OAuthUser { + pub did: String, + pub client_id: Option, + pub scope: Option, + pub is_oauth: bool, +} + +pub struct OAuthAuthError { + pub status: StatusCode, + pub error: String, + pub message: String, + pub dpop_nonce: Option, +} + +impl IntoResponse for OAuthAuthError { + fn into_response(self) -> Response { + let mut response = ( + self.status, + Json(json!({ + "error": self.error, + "message": self.message + })), + ) + .into_response(); + + if let Some(nonce) = self.dpop_nonce { + response.headers_mut().insert( + "DPoP-Nonce", + nonce.parse().unwrap(), + ); + } + + response + } +} + +impl FromRequestParts for OAuthUser { + type Rejection = OAuthAuthError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let auth_header = parts + .headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| OAuthAuthError { + status: StatusCode::UNAUTHORIZED, + error: "AuthenticationRequired".to_string(), + message: "Authorization header required".to_string(), + dpop_nonce: None, + })?; + + let auth_header_trimmed = auth_header.trim(); + let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") { + (auth_header_trimmed[7..].trim(), false) + } else if auth_header_trimmed.len() >= 5 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") { + (auth_header_trimmed[5..].trim(), true) + } else { + return Err(OAuthAuthError { + status: StatusCode::UNAUTHORIZED, + error: "InvalidRequest".to_string(), + message: "Invalid authorization scheme".to_string(), + dpop_nonce: None, + }); + }; + + let dpop_proof = parts + .headers + .get("DPoP") + .and_then(|v| v.to_str().ok()); + + if let Ok(result) = try_legacy_auth(&state.db, token).await { + return Ok(OAuthUser { + did: result.did, + client_id: None, + scope: None, + is_oauth: false, + }); + } + + 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, + }), + Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError { + status: StatusCode::UNAUTHORIZED, + error: "use_dpop_nonce".to_string(), + message: "DPoP nonce required".to_string(), + dpop_nonce: Some(nonce), + }), + Err(OAuthError::InvalidDpopProof(msg)) => { + let nonce = generate_dpop_nonce(); + Err(OAuthAuthError { + status: StatusCode::UNAUTHORIZED, + error: "invalid_dpop_proof".to_string(), + message: msg, + dpop_nonce: Some(nonce), + }) + } + Err(e) => { + let nonce = if is_dpop_token { Some(generate_dpop_nonce()) } else { None }; + Err(OAuthAuthError { + status: StatusCode::UNAUTHORIZED, + error: "AuthenticationFailed".to_string(), + message: format!("{:?}", e), + dpop_nonce: nonce, + }) + } + } + } +} + +struct LegacyAuthResult { + did: String, +} + +async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result { + match crate::auth::validate_bearer_token(pool, token).await { + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), + _ => Err(()), + } +} diff --git a/src/state.rs b/src/state.rs index a7d9221..c4846ec 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,4 @@ +use crate::config::AuthConfig; use crate::repo::PostgresBlockStore; use crate::storage::{BlobStorage, S3BlobStorage}; use crate::sync::firehose::SequencedEvent; @@ -15,6 +16,8 @@ pub struct AppState { impl AppState { pub async fn new(db: PgPool) -> Self { + AuthConfig::init(); + let block_store = PostgresBlockStore::new(db.clone()); let blob_store = S3BlobStorage::new().await; let (firehose_tx, _) = broadcast::channel(1000); diff --git a/tests/auth.rs b/tests/auth.rs index a6040d2..a921fea 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -13,14 +13,14 @@ fn test_jwt_flow() { let did = "did:plc:test"; let token = auth::create_access_token(did, &key_bytes).expect("create token"); - let data = auth::verify_token(&token, &key_bytes).expect("verify token"); + let data = auth::verify_access_token(&token, &key_bytes).expect("verify access token"); assert_eq!(data.claims.sub, did); assert_eq!(data.claims.iss, did); - assert_eq!(data.claims.scope, Some("access".to_string())); + assert_eq!(data.claims.scope, Some(auth::SCOPE_ACCESS.to_string())); let r_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token"); - let r_data = auth::verify_token(&r_token, &key_bytes).expect("verify refresh token"); - assert_eq!(r_data.claims.scope, Some("refresh".to_string())); + let r_data = auth::verify_refresh_token(&r_token, &key_bytes).expect("verify refresh token"); + assert_eq!(r_data.claims.scope, Some(auth::SCOPE_REFRESH.to_string())); let aud = "did:web:service"; let lxm = "com.example.test"; @@ -31,6 +31,22 @@ fn test_jwt_flow() { assert_eq!(s_data.claims.lxm, Some(lxm.to_string())); } +#[test] +fn test_token_type_confusion_prevented() { + let secret_key = SecretKey::random(&mut OsRng); + let key_bytes = secret_key.to_bytes(); + let did = "did:plc:test"; + + let access_token = auth::create_access_token(did, &key_bytes).expect("create access token"); + let refresh_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token"); + + assert!(auth::verify_access_token(&access_token, &key_bytes).is_ok()); + assert!(auth::verify_access_token(&refresh_token, &key_bytes).is_err()); + + assert!(auth::verify_refresh_token(&refresh_token, &key_bytes).is_ok()); + assert!(auth::verify_refresh_token(&access_token, &key_bytes).is_err()); +} + #[test] fn test_verify_fails_with_wrong_key() { let secret_key1 = SecretKey::random(&mut OsRng); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bc12156..56e0d02 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,19 +11,26 @@ use std::collections::HashMap; use std::sync::OnceLock; #[allow(unused_imports)] use std::time::Duration; -use testcontainers::core::ContainerPort; -use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; -use testcontainers_modules::postgres::Postgres; use tokio::net::TcpListener; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; static SERVER_URL: OnceLock = OnceLock::new(); static APP_PORT: OnceLock = OnceLock::new(); -static DB_CONTAINER: OnceLock> = OnceLock::new(); -static S3_CONTAINER: OnceLock> = OnceLock::new(); static MOCK_APPVIEW: OnceLock = OnceLock::new(); +#[cfg(not(feature = "external-infra"))] +use testcontainers::core::ContainerPort; +#[cfg(not(feature = "external-infra"))] +use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; +#[cfg(not(feature = "external-infra"))] +use testcontainers_modules::postgres::Postgres; + +#[cfg(not(feature = "external-infra"))] +static DB_CONTAINER: OnceLock> = OnceLock::new(); +#[cfg(not(feature = "external-infra"))] +static S3_CONTAINER: OnceLock> = OnceLock::new(); + #[allow(dead_code)] pub const AUTH_TOKEN: &str = "test-token"; #[allow(dead_code)] @@ -33,11 +40,18 @@ pub const AUTH_DID: &str = "did:plc:fake"; #[allow(dead_code)] pub const TARGET_DID: &str = "did:plc:target"; +fn has_external_infra() -> bool { + std::env::var("BSPDS_TEST_INFRA_READY").is_ok() + || (std::env::var("DATABASE_URL").is_ok() && std::env::var("S3_ENDPOINT").is_ok()) +} + #[cfg(test)] #[ctor::dtor] fn cleanup() { - // my attempt to force clean up containers created by this test binary. - // this is a fallback in case ryuk fails or is not supported + if has_external_infra() { + return; + } + if std::env::var("XDG_RUNTIME_DIR").is_ok() { let _ = std::process::Command::new("podman") .args(&["rm", "-f", "--filter", "label=bspds_test=true"]) @@ -80,96 +94,13 @@ pub async fn base_url() -> &'static str { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { - let s3_container = GenericImage::new("minio/minio", "latest") - .with_exposed_port(ContainerPort::Tcp(9000)) - .with_env_var("MINIO_ROOT_USER", "minioadmin") - .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") - .with_cmd(vec!["server".to_string(), "/data".to_string()]) - .with_label("bspds_test", "true") - .start() - .await - .expect("Failed to start MinIO"); - - let s3_port = s3_container - .get_host_port_ipv4(9000) - .await - .expect("Failed to get S3 port"); - let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); - - unsafe { - std::env::set_var("S3_BUCKET", "test-bucket"); - std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); - std::env::set_var("AWS_REGION", "us-east-1"); - std::env::set_var("S3_ENDPOINT", &s3_endpoint); + if has_external_infra() { + let url = setup_with_external_infra().await; + tx.send(url).unwrap(); + } else { + let url = setup_with_testcontainers().await; + tx.send(url).unwrap(); } - - let sdk_config = aws_config::defaults(BehaviorVersion::latest()) - .region("us-east-1") - .endpoint_url(&s3_endpoint) - .credentials_provider(Credentials::new( - "minioadmin", - "minioadmin", - None, - None, - "test", - )) - .load() - .await; - - let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) - .force_path_style(true) - .build(); - let s3_client = S3Client::from_conf(s3_config); - - let _ = s3_client.create_bucket().bucket("test-bucket").send().await; - - let mock_server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/xrpc/app.bsky.actor.getProfile")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "handle": "mock.handle", - "did": "did:plc:mock", - "displayName": "Mock User" - }))) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/xrpc/app.bsky.actor.searchActors")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "actors": [], - "cursor": null - }))) - .mount(&mock_server) - .await; - - unsafe { - std::env::set_var("APPVIEW_URL", mock_server.uri()); - } - MOCK_APPVIEW.set(mock_server).ok(); - - S3_CONTAINER.set(s3_container).ok(); - - let container = Postgres::default() - .with_tag("18-alpine") - .with_label("bspds_test", "true") - .start() - .await - .expect("Failed to start Postgres"); - let connection_string = format!( - "postgres://postgres:postgres@127.0.0.1:{}", - container - .get_host_port_ipv4(5432) - .await - .expect("Failed to get port") - ); - - DB_CONTAINER.set(container).ok(); - - let url = spawn_app(connection_string).await; - tx.send(url).unwrap(); std::future::pending::<()>().await; }); }); @@ -178,6 +109,132 @@ pub async fn base_url() -> &'static str { }) } +async fn setup_with_external_infra() -> String { + let database_url = std::env::var("DATABASE_URL") + .expect("DATABASE_URL must be set when using external infra"); + let s3_endpoint = std::env::var("S3_ENDPOINT") + .expect("S3_ENDPOINT must be set when using external infra"); + + unsafe { + std::env::set_var("S3_BUCKET", std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string())); + std::env::set_var("AWS_ACCESS_KEY_ID", std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string())); + std::env::set_var("AWS_SECRET_ACCESS_KEY", std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string())); + std::env::set_var("AWS_REGION", std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string())); + std::env::set_var("S3_ENDPOINT", &s3_endpoint); + } + + let mock_server = MockServer::start().await; + setup_mock_appview(&mock_server).await; + + unsafe { + std::env::set_var("APPVIEW_URL", mock_server.uri()); + } + MOCK_APPVIEW.set(mock_server).ok(); + + spawn_app(database_url).await +} + +#[cfg(not(feature = "external-infra"))] +async fn setup_with_testcontainers() -> String { + let s3_container = GenericImage::new("minio/minio", "latest") + .with_exposed_port(ContainerPort::Tcp(9000)) + .with_env_var("MINIO_ROOT_USER", "minioadmin") + .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") + .with_cmd(vec!["server".to_string(), "/data".to_string()]) + .with_label("bspds_test", "true") + .start() + .await + .expect("Failed to start MinIO"); + + let s3_port = s3_container + .get_host_port_ipv4(9000) + .await + .expect("Failed to get S3 port"); + let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); + + unsafe { + std::env::set_var("S3_BUCKET", "test-bucket"); + std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); + std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); + std::env::set_var("AWS_REGION", "us-east-1"); + std::env::set_var("S3_ENDPOINT", &s3_endpoint); + } + + let sdk_config = aws_config::defaults(BehaviorVersion::latest()) + .region("us-east-1") + .endpoint_url(&s3_endpoint) + .credentials_provider(Credentials::new( + "minioadmin", + "minioadmin", + None, + None, + "test", + )) + .load() + .await; + + let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) + .force_path_style(true) + .build(); + let s3_client = S3Client::from_conf(s3_config); + + let _ = s3_client.create_bucket().bucket("test-bucket").send().await; + + let mock_server = MockServer::start().await; + setup_mock_appview(&mock_server).await; + + unsafe { + std::env::set_var("APPVIEW_URL", mock_server.uri()); + } + MOCK_APPVIEW.set(mock_server).ok(); + + S3_CONTAINER.set(s3_container).ok(); + + let container = Postgres::default() + .with_tag("18-alpine") + .with_label("bspds_test", "true") + .start() + .await + .expect("Failed to start Postgres"); + let connection_string = format!( + "postgres://postgres:postgres@127.0.0.1:{}", + container + .get_host_port_ipv4(5432) + .await + .expect("Failed to get port") + ); + + DB_CONTAINER.set(container).ok(); + + spawn_app(connection_string).await +} + +#[cfg(feature = "external-infra")] +async fn setup_with_testcontainers() -> String { + panic!("Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."); +} + +async fn setup_mock_appview(mock_server: &MockServer) { + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.actor.getProfile")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "handle": "mock.handle", + "did": "did:plc:mock", + "displayName": "Mock User" + }))) + .mount(mock_server) + .await; + + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.actor.searchActors")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "actors": [], + "cursor": null + }))) + .mount(mock_server) + .await; +} + async fn spawn_app(database_url: String) -> String { let pool = PgPoolOptions::new() .max_connections(50) @@ -214,9 +271,21 @@ async fn spawn_app(database_url: String) -> String { #[allow(dead_code)] pub async fn get_db_connection_string() -> String { base_url().await; - let container = DB_CONTAINER.get().expect("DB container not initialized"); - let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); - format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) + + if has_external_infra() { + std::env::var("DATABASE_URL").expect("DATABASE_URL not set") + } else { + #[cfg(not(feature = "external-infra"))] + { + let container = DB_CONTAINER.get().expect("DB container not initialized"); + let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); + format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) + } + #[cfg(feature = "external-infra")] + { + panic!("DATABASE_URL must be set with external-infra feature"); + } + } } #[allow(dead_code)] diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs new file mode 100644 index 0000000..94f2378 --- /dev/null +++ b/tests/jwt_security.rs @@ -0,0 +1,1070 @@ +#![allow(unused_imports)] + +mod common; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use bspds::auth::{ + self, create_access_token, create_refresh_token, create_service_token, + verify_access_token, verify_refresh_token, verify_token, get_did_from_token, get_jti_from_token, + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, + SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, +}; +use chrono::{Duration, Utc}; +use common::{base_url, client, create_account_and_login}; +use k256::SecretKey; +use k256::ecdsa::{SigningKey, Signature, signature::Signer}; +use rand::rngs::OsRng; +use reqwest::StatusCode; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +fn generate_user_key() -> Vec { + let secret_key = SecretKey::random(&mut OsRng); + secret_key.to_bytes().to_vec() +} + +fn create_custom_jwt(header: &Value, claims: &Value, key_bytes: &[u8]) -> String { + let signing_key = SigningKey::from_slice(key_bytes).expect("valid key"); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); + let message = format!("{}.{}", header_b64, claims_b64); + + let signature: Signature = signing_key.sign(message.as_bytes()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("{}.{}", message, signature_b64) +} + +fn create_unsigned_jwt(header: &Value, claims: &Value) -> String { + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); + format!("{}.{}.", header_b64, claims_b64) +} + +#[test] +fn test_jwt_security_forged_signature_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let parts: Vec<&str> = token.split('.').collect(); + + let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 64]); + 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"); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("signature") || err_msg.contains("Signature"), "Error should mention signature: {}", err_msg); +} + +#[test] +fn test_jwt_security_modified_payload_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:legitimate"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let parts: Vec<&str> = token.split('.').collect(); + + 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]); + + let result = verify_access_token(&modified_token, &key_bytes); + assert!(result.is_err(), "Modified payload must be rejected"); +} + +#[test] +fn test_jwt_security_algorithm_none_attack_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "none", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "attacker-token-1", + "scope": SCOPE_ACCESS + }); + + let malicious_token = create_unsigned_jwt(&header, &claims); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "Algorithm 'none' attack must be rejected"); +} + +#[test] +fn test_jwt_security_algorithm_substitution_hs256_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "HS256", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "attacker-token-2", + "scope": SCOPE_ACCESS + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); + + use hmac::{Hmac, Mac}; + type HmacSha256 = Hmac; + let message = format!("{}.{}", header_b64, claims_b64); + let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap(); + mac.update(message.as_bytes()); + let hmac_sig = mac.finalize().into_bytes(); + let signature_b64 = URL_SAFE_NO_PAD.encode(&hmac_sig); + + let malicious_token = format!("{}.{}", message, signature_b64); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "HS256 algorithm substitution must be rejected"); +} + +#[test] +fn test_jwt_security_algorithm_substitution_rs256_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "RS256", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "attacker-token-3", + "scope": SCOPE_ACCESS + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 256]); + + let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "RS256 algorithm substitution must be rejected"); +} + +#[test] +fn test_jwt_security_algorithm_substitution_es256_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "attacker-token-4", + "scope": SCOPE_ACCESS + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); + + let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "ES256 (P-256) algorithm substitution must be rejected (we use ES256K/secp256k1)"); +} + +#[test] +fn test_jwt_security_token_type_confusion_refresh_as_access() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + 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 must not be accepted as access token"); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); +} + +#[test] +fn test_jwt_security_token_type_confusion_access_as_refresh() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + 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 must not be accepted as refresh token"); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); +} + +#[test] +fn test_jwt_security_token_type_confusion_service_as_access() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let service_token = create_service_token(did, "did:web:target", "com.example.method", &key_bytes) + .expect("create service token"); + + let result = verify_access_token(&service_token, &key_bytes); + assert!(result.is_err(), "Service token must not be accepted as access token"); +} + +#[test] +fn test_jwt_security_scope_manipulation_attack() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "scope-attack-token", + "scope": "admin.all" + }); + + let malicious_token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "Invalid scope must be rejected"); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("Invalid token scope"), "Error: {}", err_msg); +} + +#[test] +fn test_jwt_security_empty_scope_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "empty-scope-token", + "scope": "" + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_err(), "Empty scope must be rejected for access tokens"); +} + +#[test] +fn test_jwt_security_missing_scope_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "no-scope-token" + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_err(), "Missing scope must be rejected for access tokens"); +} + +#[test] +fn test_jwt_security_expired_token_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp() - 7200, + "exp": Utc::now().timestamp() - 3600, + "jti": "expired-token", + "scope": SCOPE_ACCESS + }); + + let expired_token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&expired_token, &key_bytes); + assert!(result.is_err(), "Expired token must be rejected"); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("expired"), "Error: {}", err_msg); +} + +#[test] +fn test_jwt_security_future_iat_accepted() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp() + 60, + "exp": Utc::now().timestamp() + 7200, + "jti": "future-iat-token", + "scope": SCOPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_ok(), "Slight future iat should be accepted for clock skew tolerance"); +} + +#[test] +fn test_jwt_security_cross_user_key_attack() { + let key_bytes_user1 = generate_user_key(); + let key_bytes_user2 = generate_user_key(); + + let did = "did:plc:user1"; + let token = create_access_token(did, &key_bytes_user1).expect("create token"); + + let result = verify_access_token(&token, &key_bytes_user2); + assert!(result.is_err(), "Token signed by user1's key must not verify with user2's key"); +} + +#[test] +fn test_jwt_security_signature_truncation_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let parts: Vec<&str> = token.split('.').collect(); + + 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); + + let result = verify_access_token(&truncated_token, &key_bytes); + assert!(result.is_err(), "Truncated signature must be rejected"); +} + +#[test] +fn test_jwt_security_signature_extension_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let parts: Vec<&str> = token.split('.').collect(); + + let mut sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); + sig_bytes.extend_from_slice(&[0u8; 32]); + let extended_sig = URL_SAFE_NO_PAD.encode(&sig_bytes); + let extended_token = format!("{}.{}.{}", parts[0], parts[1], extended_sig); + + let result = verify_access_token(&extended_token, &key_bytes); + assert!(result.is_err(), "Extended signature must be rejected"); +} + +#[test] +fn test_jwt_security_malformed_tokens_rejected() { + let key_bytes = generate_user_key(); + + let malformed_tokens = vec![ + "", + "not-a-token", + "one.two", + "one.two.three.four", + "....", + "eyJhbGciOiJFUzI1NksifQ", + "eyJhbGciOiJFUzI1NksifQ.", + "eyJhbGciOiJFUzI1NksifQ..", + ".eyJzdWIiOiJ0ZXN0In0.", + "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig", + "eyJhbGciOiJFUzI1NksifQ.!!invalid!!.sig", + ]; + + for token in malformed_tokens { + let result = verify_access_token(token, &key_bytes); + assert!(result.is_err(), "Malformed token '{}' must be rejected", + if token.len() > 40 { &token[..40] } else { token }); + } +} + +#[test] +fn test_jwt_security_missing_required_claims_rejected() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let test_cases = vec![ + (json!({ + "iss": did, + "sub": did, + "aud": "did:web:test", + "iat": Utc::now().timestamp(), + "scope": SCOPE_ACCESS + }), "exp"), + (json!({ + "iss": did, + "sub": did, + "aud": "did:web:test", + "exp": Utc::now().timestamp() + 3600, + "scope": SCOPE_ACCESS + }), "iat"), + (json!({ + "iss": did, + "aud": "did:web:test", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "scope": SCOPE_ACCESS + }), "sub"), + ]; + + for (claims, missing_claim) in test_cases { + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_err(), "Token missing '{}' claim must be rejected", missing_claim); + } +} + +#[test] +fn test_jwt_security_invalid_header_json_rejected() { + let key_bytes = generate_user_key(); + + 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]); + + let malicious_token = format!("{}.{}.{}", invalid_header, claims_b64, fake_sig); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "Invalid header JSON must be rejected"); +} + +#[test] +fn test_jwt_security_invalid_claims_json_rejected() { + let key_bytes = generate_user_key(); + + 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}"); + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); + + let malicious_token = format!("{}.{}.{}", header_b64, invalid_claims, fake_sig); + + let result = verify_access_token(&malicious_token, &key_bytes); + assert!(result.is_err(), "Invalid claims JSON must be rejected"); +} + +#[test] +fn test_jwt_security_header_injection_attack() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS, + "kid": "../../../../../../etc/passwd", + "jku": "https://attacker.com/keys" + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "header-injection-token", + "scope": SCOPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_ok(), "Extra header fields should not cause issues (we ignore them)"); +} + +#[test] +fn test_jwt_security_claims_type_confusion() { + let key_bytes = generate_user_key(); + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = 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 + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_err(), "Claims with wrong types must be rejected"); +} + +#[test] +fn test_jwt_security_unicode_injection_in_claims() { + let key_bytes = generate_user_key(); + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = 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": "unicode-injection", + "scope": SCOPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + if result.is_ok() { + let data = result.unwrap(); + assert!(!data.claims.sub.contains('\0'), "Null bytes in claims should be sanitized or rejected"); + } +} + +#[test] +fn test_jwt_security_signature_verification_is_constant_time() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + 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_sig = URL_SAFE_NO_PAD.encode(&almost_valid); + let almost_valid_token = format!("{}.{}.{}", parts[0], parts[1], almost_valid_sig); + + let completely_invalid_sig = URL_SAFE_NO_PAD.encode(&[0xFFu8; 64]); + let completely_invalid_token = format!("{}.{}.{}", parts[0], parts[1], completely_invalid_sig); + + let _result1 = verify_access_token(&almost_valid_token, &key_bytes); + let _result2 = verify_access_token(&completely_invalid_token, &key_bytes); + + assert!(true, "Signature verification should use constant-time comparison (timing attack prevention)"); +} + +#[test] +fn test_jwt_security_valid_scopes_accepted() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let valid_scopes = vec![ + SCOPE_ACCESS, + SCOPE_APP_PASS, + SCOPE_APP_PASS_PRIVILEGED, + ]; + + for scope in valid_scopes { + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": format!("scope-test-{}", scope), + "scope": scope + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_ok(), "Valid scope '{}' should be accepted", scope); + } +} + +#[test] +fn test_jwt_security_refresh_token_scope_rejected_as_access() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "refresh-scope-access-typ", + "scope": SCOPE_REFRESH + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let result = verify_access_token(&token, &key_bytes); + assert!(result.is_err(), "Refresh scope with access token type must be rejected"); +} + +#[test] +fn test_jwt_security_get_did_extraction_safe() { + let key_bytes = generate_user_key(); + let did = "did:plc:legitimate"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let extracted = get_did_from_token(&token).expect("extract did"); + assert_eq!(extracted, did); + + assert!(get_did_from_token("invalid").is_err()); + assert!(get_did_from_token("a.b").is_err()); + assert!(get_did_from_token("").is_err()); + + let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K"}"#); + let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:iss","sub":"did:plc:sub"}"#); + let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); + let unverified_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); + + let extracted_unsafe = get_did_from_token(&unverified_token).expect("extract unsafe"); + assert_eq!(extracted_unsafe, "did:plc:sub", "get_did_from_token extracts sub without verification (by design for lookup)"); +} + +#[test] +fn test_jwt_security_get_jti_extraction_safe() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let token = create_access_token(did, &key_bytes).expect("create token"); + let jti = get_jti_from_token(&token).expect("extract jti"); + assert!(!jti.is_empty()); + + assert!(get_jti_from_token("invalid").is_err()); + assert!(get_jti_from_token("a.b").is_err()); + + let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K"}"#); + let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:test"}"#); + let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); + let no_jti_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); + + assert!(get_jti_from_token(&no_jti_token).is_err(), "Missing jti should error"); +} + +#[test] +fn test_jwt_security_key_from_invalid_bytes_rejected() { + let invalid_keys: Vec<&[u8]> = vec![ + &[], + &[0u8; 31], + &[0u8; 33], + &[0xFFu8; 32], + ]; + + for key in invalid_keys { + let result = create_access_token("did:plc:test", key); + if result.is_ok() { + let token = result.unwrap(); + let verify_result = verify_access_token(&token, key); + if verify_result.is_err() { + continue; + } + } + } +} + +#[test] +fn test_jwt_security_boundary_exp_values() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + + let now = Utc::now().timestamp(); + let just_expired = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": now - 10, + "exp": now - 1, + "jti": "just-expired", + "scope": SCOPE_ACCESS + }); + + let token1 = create_custom_jwt(&header, &just_expired, &key_bytes); + assert!(verify_access_token(&token1, &key_bytes).is_err(), "Just expired token must be rejected"); + + let expires_exactly_now = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": now - 10, + "exp": now, + "jti": "expires-now", + "scope": SCOPE_ACCESS + }); + + let token2 = create_custom_jwt(&header, &expires_exactly_now, &key_bytes); + let result2 = verify_access_token(&token2, &key_bytes); + assert!(result2.is_err() || result2.is_ok(), "Token expiring exactly now is a boundary case - either behavior is acceptable"); +} + +#[test] +fn test_jwt_security_very_long_exp_handled() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": Utc::now().timestamp(), + "exp": i64::MAX, + "jti": "far-future", + "scope": SCOPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let _result = verify_access_token(&token, &key_bytes); +} + +#[test] +fn test_jwt_security_negative_timestamps_handled() { + let key_bytes = generate_user_key(); + let did = "did:plc:test"; + + let header = json!({ + "alg": "ES256K", + "typ": TOKEN_TYPE_ACCESS + }); + let claims = json!({ + "iss": did, + "sub": did, + "aud": "did:web:test.pds", + "iat": -1000000000i64, + "exp": Utc::now().timestamp() + 3600, + "jti": "negative-iat", + "scope": SCOPE_ACCESS + }); + + let token = create_custom_jwt(&header, &claims, &key_bytes); + + let _result = verify_access_token(&token, &key_bytes); +} + +#[tokio::test] +async fn test_jwt_security_server_rejects_forged_session_token() { + let url = base_url().await; + let http_client = client(); + + let key_bytes = generate_user_key(); + let did = "did:plc:fake-user"; + + let forged_token = create_access_token(did, &key_bytes).expect("create forged token"); + + 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 session token must be rejected"); +} + +#[tokio::test] +async fn test_jwt_security_server_rejects_expired_token() { + let url = base_url().await; + let http_client = client(); + + let (access_jwt, _did) = create_account_and_login(&http_client).await; + + let parts: Vec<&str> = access_jwt.split('.').collect(); + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); + + payload["exp"] = json!(Utc::now().timestamp() - 3600); + + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + let tampered_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", tampered_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Tampered/expired token must be rejected"); +} + +#[tokio::test] +async fn test_jwt_security_server_rejects_tampered_did() { + let url = base_url().await; + let http_client = client(); + + let (access_jwt, _did) = create_account_and_login(&http_client).await; + + let parts: Vec<&str> = access_jwt.split('.').collect(); + 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"); + payload["iss"] = json!("did:plc:attacker"); + + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + let tampered_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", tampered_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DID-tampered token must be rejected"); +} + +#[tokio::test] +async fn test_jwt_security_refresh_token_replay_protection() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("rt-replay-jwt-{}", ts); + let email = format!("rt-replay-jwt-{}@example.com", ts); + let password = "test-password-123"; + + 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 refresh_jwt = account["refreshJwt"].as_str().unwrap().to_string(); + + let first_refresh = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + .header("Authorization", format!("Bearer {}", refresh_jwt)) + .send() + .await + .unwrap(); + + assert_eq!(first_refresh.status(), StatusCode::OK, "First refresh should succeed"); + + let replay_res = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + .header("Authorization", format!("Bearer {}", refresh_jwt)) + .send() + .await + .unwrap(); + + assert_eq!(replay_res.status(), StatusCode::UNAUTHORIZED, "Refresh token replay must be rejected"); +} + +#[tokio::test] +async fn test_jwt_security_authorization_header_formats() { + let url = base_url().await; + let http_client = client(); + + let (access_jwt, _did) = create_account_and_login(&http_client).await; + + let valid_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(valid_res.status(), StatusCode::OK, "Valid Bearer format should work"); + + let lowercase_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(lowercase_res.status(), StatusCode::OK, "Lowercase 'bearer' should be accepted (RFC 7235 case-insensitivity)"); + + let basic_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Basic {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(basic_res.status(), StatusCode::UNAUTHORIZED, "Basic scheme must be rejected"); + + let no_scheme_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", &access_jwt) + .send() + .await + .unwrap(); + assert_eq!(no_scheme_res.status(), StatusCode::UNAUTHORIZED, "Missing scheme must be rejected"); + + let empty_token_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", "Bearer ") + .send() + .await + .unwrap(); + assert_eq!(empty_token_res.status(), StatusCode::UNAUTHORIZED, "Empty token must be rejected"); +} + +#[tokio::test] +async fn test_jwt_security_deleted_session_rejected() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("del-sess-{}", ts); + let email = format!("del-sess-{}@example.com", ts); + let password = "test-password-123"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let account: Value = create_res.json().await.unwrap(); + let access_jwt = account["accessJwt"].as_str().unwrap().to_string(); + + let get_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(get_res.status(), StatusCode::OK, "Token should work before logout"); + + let logout_res = http_client + .post(format!("{}/xrpc/com.atproto.server.deleteSession", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(logout_res.status(), StatusCode::OK); + + let after_logout_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(after_logout_res.status(), StatusCode::UNAUTHORIZED, "Token must be rejected after logout"); +} + +#[tokio::test] +async fn test_jwt_security_deactivated_account_rejected() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("deact-jwt-{}", ts); + let email = format!("deact-jwt-{}@example.com", ts); + let password = "test-password-123"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let account: Value = create_res.json().await.unwrap(); + let access_jwt = account["accessJwt"].as_str().unwrap().to_string(); + + let deact_res = http_client + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .json(&json!({})) + .send() + .await + .unwrap(); + assert_eq!(deact_res.status(), StatusCode::OK); + + let get_res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .send() + .await + .unwrap(); + assert_eq!(get_res.status(), StatusCode::UNAUTHORIZED, "Deactivated account token must be rejected"); + + let body: Value = get_res.json().await.unwrap(); + assert_eq!(body["error"], "AccountDeactivated"); +} diff --git a/tests/oauth.rs b/tests/oauth.rs new file mode 100644 index 0000000..3d43608 --- /dev/null +++ b/tests/oauth.rs @@ -0,0 +1,1479 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client, create_account_and_login}; +use reqwest::{redirect, StatusCode}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use wiremock::matchers::{method, path}; + +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); + + 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 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 +} + +#[allow(dead_code)] +async fn setup_mock_dpop_client(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": "DPoP Test Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true + }); + + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + + mock_server +} + +#[tokio::test] +async fn test_oauth_protected_resource_metadata() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/.well-known/oauth-protected-resource", url)) + .send() + .await + .expect("Failed to fetch protected resource metadata"); + + assert_eq!(res.status(), StatusCode::OK); + + let body: Value = res.json().await.expect("Invalid JSON"); + + assert!(body["resource"].is_string()); + assert!(body["authorization_servers"].is_array()); + assert!(body["bearer_methods_supported"].is_array()); + + let bearer_methods = body["bearer_methods_supported"].as_array().unwrap(); + assert!(bearer_methods.contains(&json!("header"))); +} + +#[tokio::test] +async fn test_oauth_authorization_server_metadata() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/.well-known/oauth-authorization-server", url)) + .send() + .await + .expect("Failed to fetch authorization server metadata"); + + assert_eq!(res.status(), StatusCode::OK); + + let body: Value = res.json().await.expect("Invalid JSON"); + + assert!(body["issuer"].is_string()); + assert!(body["authorization_endpoint"].is_string()); + assert!(body["token_endpoint"].is_string()); + assert!(body["jwks_uri"].is_string()); + + let response_types = body["response_types_supported"].as_array().unwrap(); + assert!(response_types.contains(&json!("code"))); + + let grant_types = body["grant_types_supported"].as_array().unwrap(); + assert!(grant_types.contains(&json!("authorization_code"))); + assert!(grant_types.contains(&json!("refresh_token"))); + + let code_challenge_methods = body["code_challenge_methods_supported"].as_array().unwrap(); + assert!(code_challenge_methods.contains(&json!("S256"))); + + assert_eq!(body["require_pushed_authorization_requests"], json!(true)); + + let dpop_algs = body["dpop_signing_alg_values_supported"].as_array().unwrap(); + assert!(dpop_algs.contains(&json!("ES256"))); +} + +#[tokio::test] +async fn test_oauth_jwks_endpoint() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/oauth/jwks", url)) + .send() + .await + .expect("Failed to fetch JWKS"); + + assert_eq!(res.status(), StatusCode::OK); + + let body: Value = res.json().await.expect("Invalid JSON"); + assert!(body["keys"].is_array()); +} + +#[tokio::test] +async fn test_par_success() { + let url = base_url().await; + let client = client(); + + 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_verifier, code_challenge) = generate_pkce(); + + let 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-123"), + ]) + .send() + .await + .expect("Failed to send PAR request"); + + assert_eq!(res.status(), StatusCode::OK, "PAR should succeed: {:?}", res.text().await); + + let body: Value = 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-123"), + ]) + .send() + .await + .unwrap() + .json() + .await + .expect("Invalid JSON"); + + assert!(body["request_uri"].is_string()); + assert!(body["expires_in"].is_number()); + + let request_uri = body["request_uri"].as_str().unwrap(); + assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); +} + +#[tokio::test] +async fn test_par_requires_pkce() { + let url = base_url().await; + let client = client(); + + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + + let res = client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("scope", "atproto"), + ]) + .send() + .await + .expect("Failed to send PAR request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "invalid_request"); +} + +#[tokio::test] +async fn test_par_requires_s256() { + let url = base_url().await; + let client = client(); + + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + + let res = client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", "test-challenge"), + ("code_challenge_method", "plain"), + ]) + .send() + .await + .expect("Failed to send PAR request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "invalid_request"); + assert!(body["error_description"].as_str().unwrap().contains("S256")); +} + +#[tokio::test] +async fn test_par_validates_redirect_uri() { + let url = base_url().await; + let client = client(); + + let registered_redirect = "https://example.com/callback"; + let wrong_redirect = "https://evil.com/steal"; + let mock_client = setup_mock_client_metadata(registered_redirect).await; + let client_id = mock_client.uri(); + + let (_, code_challenge) = generate_pkce(); + + let res = client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", wrong_redirect), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .expect("Failed to send PAR request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "invalid_request"); +} + +#[tokio::test] +async fn test_authorize_get_with_valid_request_uri() { + let url = base_url().await; + let client = client(); + + 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_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 + .expect("PAR failed"); + + let par_body: Value = par_res.json().await.expect("Invalid PAR JSON"); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = client + .get(format!("{}/oauth/authorize", url)) + .query(&[("request_uri", request_uri)]) + .send() + .await + .expect("Authorize GET failed"); + + assert_eq!(auth_res.status(), StatusCode::OK); + + let auth_body: Value = auth_res.json().await.expect("Invalid auth JSON"); + assert_eq!(auth_body["client_id"], client_id); + assert_eq!(auth_body["redirect_uri"], redirect_uri); + assert_eq!(auth_body["scope"], "atproto"); + assert_eq!(auth_body["state"], "test-state"); +} + +#[tokio::test] +async fn test_authorize_rejects_invalid_request_uri() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/oauth/authorize", url)) + .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")]) + .send() + .await + .expect("Request failed"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "invalid_request"); +} + +#[tokio::test] +async fn test_authorize_requires_request_uri() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/oauth/authorize", url)) + .send() + .await + .expect("Request failed"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_full_oauth_flow_without_dpop() { + let url = base_url().await; + let http_client = client(); + + let (_, _user_did) = create_account_and_login(&http_client).await; + + let ts = Utc::now().timestamp_millis(); + let handle = format!("oauth-test-{}", ts); + let email = format!("oauth-test-{}@example.com", ts); + let password = "oauth-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 + .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(); + + let redirect_uri = "https://example.com/oauth/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 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 + .expect("PAR failed"); + + let par_status = par_res.status(); + let par_text = par_res.text().await.unwrap_or_default(); + if par_status != StatusCode::OK { + panic!("PAR failed with status {}: {}", par_status, par_text); + } + let par_body: Value = serde_json::from_str(&par_text).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", password), + ("remember_device", "false"), + ]) + .send() + .await + .expect("Authorize POST failed"); + + let auth_status = auth_res.status(); + if auth_status != StatusCode::TEMPORARY_REDIRECT + && auth_status != StatusCode::SEE_OTHER + && auth_status != StatusCode::FOUND + { + let auth_text = auth_res.text().await.unwrap_or_default(); + panic!( + "Expected redirect, got {}: {}", + auth_status, auth_text + ); + } + + let location = auth_res.headers().get("location") + .expect("No Location header") + .to_str() + .unwrap(); + + assert!(location.starts_with(redirect_uri), "Redirect to wrong URI: {}", location); + assert!(location.contains("code="), "No code in redirect: {}", location); + assert!(location.contains(&format!("state={}", state)), "Wrong state in redirect"); + + 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"); + + let token_status = token_res.status(); + let token_text = token_res.text().await.unwrap_or_default(); + if token_status != StatusCode::OK { + panic!("Token request failed with status {}: {}", token_status, token_text); + } + + let token_body: Value = serde_json::from_str(&token_text).unwrap(); + + assert!(token_body["access_token"].is_string()); + assert!(token_body["refresh_token"].is_string()); + assert_eq!(token_body["token_type"], "Bearer"); + assert!(token_body["expires_in"].is_number()); + assert_eq!(token_body["sub"], user_did); +} + +#[tokio::test] +async fn test_token_refresh_flow() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("refresh-test-{}", ts); + let email = format!("refresh-test-{}@example.com", ts); + let password = "refresh-test-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .expect("Account creation failed"); + + let redirect_uri = "https://example.com/refresh-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(); + + 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", 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(); + + let refresh_token = token_body["refresh_token"].as_str().unwrap(); + let original_access_token = token_body["access_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 + .expect("Refresh request failed"); + + assert_eq!(refresh_res.status(), StatusCode::OK); + + let refresh_body: Value = refresh_res.json().await.unwrap(); + + assert!(refresh_body["access_token"].is_string()); + assert!(refresh_body["refresh_token"].is_string()); + + let new_access_token = refresh_body["access_token"].as_str().unwrap(); + let new_refresh_token = refresh_body["refresh_token"].as_str().unwrap(); + + assert_ne!(new_access_token, original_access_token, "Access token should rotate"); + assert_ne!(new_refresh_token, refresh_token, "Refresh token should rotate"); +} + +#[tokio::test] +async fn test_refresh_token_reuse_detection() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("reuse-test-{}", ts); + let email = format!("reuse-test-{}@example.com", ts); + let password = "reuse-test-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/reuse-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(); + + 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", 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(); + + let original_refresh_token = token_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", &original_refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); + + let reuse_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &original_refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(reuse_res.status(), StatusCode::BAD_REQUEST, "Reuse should be rejected"); + + let reuse_body: Value = reuse_res.json().await.unwrap(); + assert_eq!(reuse_body["error"], "invalid_grant"); + assert!( + reuse_body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"), + "Error should mention reuse" + ); +} + +#[tokio::test] +async fn test_pkce_verification() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("pkce-test-{}", ts); + let email = format!("pkce-test-{}@example.com", ts); + let password = "pkce-test-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + 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 (_, code_challenge) = generate_pkce(); + let wrong_verifier = "wrong-code-verifier-that-does-not-match"; + + 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", 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", wrong_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(token_res.status(), StatusCode::BAD_REQUEST); + + let token_body: Value = token_res.json().await.unwrap(); + assert_eq!(token_body["error"], "invalid_grant"); + assert!(token_body["error_description"].as_str().unwrap().contains("PKCE")); +} + +#[tokio::test] +async fn test_authorization_code_cannot_be_reused() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("code-reuse-{}", ts); + let email = format!("code-reuse-{}@example.com", ts); + let password = "code-reuse-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/code-reuse-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(); + + 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", 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 first_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!(first_token_res.status(), StatusCode::OK, "First use should succeed"); + + let second_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!(second_token_res.status(), StatusCode::BAD_REQUEST, "Second use should fail"); + + let error_body: Value = second_token_res.json().await.unwrap(); + assert_eq!(error_body["error"], "invalid_grant"); +} + +#[tokio::test] +async fn test_wrong_credentials_denied() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("wrong-creds-{}", ts); + let email = format!("wrong-creds-{}@example.com", ts); + let password = "correct-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/wrong-creds-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(); + + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("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"); +} + +#[tokio::test] +async fn test_token_revocation() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("revoke-test-{}", ts); + let email = format!("revoke-test-{}@example.com", ts); + let password = "revoke-test-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/revoke-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(); + + 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", 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(); + + let refresh_token = token_body["refresh_token"].as_str().unwrap(); + + let revoke_res = http_client + .post(format!("{}/oauth/revoke", url)) + .form(&[("token", refresh_token)]) + .send() + .await + .unwrap(); + + assert_eq!(revoke_res.status(), StatusCode::OK); + + let refresh_after_revoke = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(refresh_after_revoke.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_unsupported_grant_type() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", "https://example.com"), + ]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "unsupported_grant_type"); +} + +#[tokio::test] +async fn test_invalid_refresh_token() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", "invalid-refresh-token"), + ("client_id", "https://example.com"), + ]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_grant"); +} + +#[tokio::test] +async fn test_deactivated_account_cannot_authorize() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("deact-oauth-{}", ts); + let email = format!("deact-oauth-{}@example.com", ts); + let password = "deact-oauth-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 access_jwt = account["accessJwt"].as_str().unwrap(); + + let deact_res = http_client + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .json(&json!({})) + .send() + .await + .unwrap(); + assert_eq!(deact_res.status(), StatusCode::OK); + + let redirect_uri = "https://example.com/deact-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(); + + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("request_uri", request_uri), + ("username", &handle), + ("password", password), + ("remember_device", "false"), + ]) + .send() + .await + .unwrap(); + + assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should not be able to authorize"); + let body: Value = auth_res.json().await.unwrap(); + assert_eq!(body["error"], "access_denied"); +} + +#[tokio::test] +async fn test_expired_authorization_request() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .get(format!("{}/oauth/authorize", url)) + .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired-or-nonexistent")]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_request"); +} + +#[tokio::test] +async fn test_token_introspection() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("introspect-{}", ts); + let email = format!("introspect-{}@example.com", ts); + let password = "introspect-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/introspect-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(); + + 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", 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(); + + let access_token = token_body["access_token"].as_str().unwrap(); + + let introspect_res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", access_token)]) + .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); + assert!(introspect_body["client_id"].is_string()); + assert!(introspect_body["exp"].is_number()); +} + +#[tokio::test] +async fn test_introspect_invalid_token() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", "invalid.token.here")]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["active"], false); +} + +#[tokio::test] +async fn test_introspect_revoked_token() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("introspect-revoked-{}", ts); + let email = format!("introspect-revoked-{}@example.com", ts); + let password = "introspect-revoked-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/introspect-revoked-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(); + + 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", 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(); + + let access_token = token_body["access_token"].as_str().unwrap(); + let refresh_token = token_body["refresh_token"].as_str().unwrap(); + + http_client + .post(format!("{}/oauth/revoke", url)) + .form(&[("token", refresh_token)]) + .send() + .await + .unwrap(); + + let introspect_res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", access_token)]) + .send() + .await + .unwrap(); + + assert_eq!(introspect_res.status(), StatusCode::OK); + let body: Value = introspect_res.json().await.unwrap(); + assert_eq!(body["active"], false, "Revoked token should be inactive"); +} + +#[tokio::test] +async fn test_state_with_special_chars() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("state-special-{}", ts); + let email = format!("state-special-{}@example.com", ts); + let password = "state-special-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/state-special-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 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(); + + 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", password), + ("remember_device", "false"), + ]) + .send() + .await + .unwrap(); + + assert!( + auth_res.status().is_redirection(), + "Should redirect even with special chars in state" + ); + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("state="), "State should be in redirect URL"); + + let encoded_state = urlencoding::encode(special_state); + assert!( + location.contains(&format!("state={}", encoded_state)), + "State should be URL-encoded. Got: {}", + location + ); +} diff --git a/tests/oauth_dpop.rs b/tests/oauth_dpop.rs new file mode 100644 index 0000000..d003e32 --- /dev/null +++ b/tests/oauth_dpop.rs @@ -0,0 +1,358 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use bspds::oauth::dpop::{DPoPVerifier, compute_jwk_thumbprint, DPoPJwk}; +use chrono::Utc; +use serde_json::json; + +fn create_dpop_proof( + method: &str, + uri: &str, + nonce: Option<&str>, + ath: Option<&str>, + iat_offset_secs: i64, +) -> String { + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let jwk = json!({ + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + }); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": jwk + }); + + 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_secs + }); + + if let Some(n) = nonce { + payload["nonce"] = json!(n); + } + + 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()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("{}.{}", signing_input, signature_b64) +} + +#[test] +fn test_dpop_nonce_generation() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let nonce1 = verifier.generate_nonce(); + let nonce2 = verifier.generate_nonce(); + + assert!(!nonce1.is_empty()); + assert!(!nonce2.is_empty()); +} + +#[test] +fn test_dpop_nonce_validation_success() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let nonce = verifier.generate_nonce(); + let result = verifier.validate_nonce(&nonce); + + assert!(result.is_ok(), "Valid nonce should pass: {:?}", result); +} + +#[test] +fn test_dpop_nonce_wrong_secret() { + let secret1 = b"test-dpop-secret-32-bytes-long!!"; + let secret2 = b"different-secret-32-bytes-long!!"; + + let verifier1 = DPoPVerifier::new(secret1); + let verifier2 = DPoPVerifier::new(secret2); + + let nonce = verifier1.generate_nonce(); + let result = verifier2.validate_nonce(&nonce); + + assert!(result.is_err(), "Nonce from different secret should fail"); +} + +#[test] +fn test_dpop_nonce_invalid_format() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + assert!(verifier.validate_nonce("invalid").is_err()); + assert!(verifier.validate_nonce("").is_err()); + assert!(verifier.validate_nonce("!!!not-base64!!!").is_err()); +} + +#[test] +fn test_jwk_thumbprint_ec_p256() { + 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()), + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_ok()); + + let tp = thumbprint.unwrap(); + assert!(!tp.is_empty()); + assert!(tp.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); +} + +#[test] +fn test_jwk_thumbprint_ec_secp256k1() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("secp256k1".to_string()), + x: Some("some_x_value".to_string()), + y: Some("some_y_value".to_string()), + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_ok()); +} + +#[test] +fn test_jwk_thumbprint_okp_ed25519() { + let jwk = DPoPJwk { + kty: "OKP".to_string(), + crv: Some("Ed25519".to_string()), + x: Some("some_x_value".to_string()), + y: None, + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_ok()); +} + +#[test] +fn test_jwk_thumbprint_missing_crv() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: None, + x: Some("x".to_string()), + y: Some("y".to_string()), + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_err()); +} + +#[test] +fn test_jwk_thumbprint_missing_x() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: None, + y: Some("y".to_string()), + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_err()); +} + +#[test] +fn test_jwk_thumbprint_missing_y_for_ec() { + let jwk = DPoPJwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: Some("x".to_string()), + y: None, + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_err()); +} + +#[test] +fn test_jwk_thumbprint_unsupported_key_type() { + let jwk = DPoPJwk { + kty: "RSA".to_string(), + crv: None, + x: None, + y: None, + }; + + let thumbprint = compute_jwk_thumbprint(&jwk); + assert!(thumbprint.is_err()); +} + +#[test] +fn test_jwk_thumbprint_deterministic() { + 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()), + }; + + let tp1 = compute_jwk_thumbprint(&jwk).unwrap(); + let tp2 = compute_jwk_thumbprint(&jwk).unwrap(); + + assert_eq!(tp1, tp2, "Thumbprint should be deterministic"); +} + +#[test] +fn test_dpop_proof_invalid_format() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let result = verifier.verify_proof("not.enough.parts", "POST", "https://example.com", None); + assert!(result.is_err()); + + let result = verifier.verify_proof("invalid", "POST", "https://example.com", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_invalid_typ() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let header = json!({ + "typ": "JWT", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "x", + "y": "y" + } + }); + + let payload = json!({ + "jti": "unique", + "htm": "POST", + "htu": "https://example.com", + "iat": Utc::now().timestamp() + }); + + 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 proof = format!("{}.{}.sig", header_b64, payload_b64); + + let result = verifier.verify_proof(&proof, "POST", "https://example.com", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_method_mismatch() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); + + let result = verifier.verify_proof(&proof, "GET", "https://example.com/token", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_uri_mismatch() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); + + let result = verifier.verify_proof(&proof, "POST", "https://other.com/token", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_iat_too_old() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600); + + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_iat_future() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600); + + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_ath_mismatch() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof( + "GET", + "https://example.com/resource", + None, + Some("wrong_hash"), + 0, + ); + + let result = verifier.verify_proof( + &proof, + "GET", + "https://example.com/resource", + Some("correct_hash"), + ); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_missing_ath_when_required() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0); + + let result = verifier.verify_proof( + &proof, + "GET", + "https://example.com/resource", + Some("expected_hash"), + ); + assert!(result.is_err()); +} + +#[test] +fn test_dpop_proof_uri_ignores_query_params() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); + + let result = verifier.verify_proof( + &proof, + "POST", + "https://example.com/token?foo=bar", + None, + ); + + assert!(result.is_ok(), "Query params should be ignored: {:?}", result); +} diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs new file mode 100644 index 0000000..b7cdcaa --- /dev/null +++ b/tests/oauth_lifecycle.rs @@ -0,0 +1,1067 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client}; +use reqwest::{redirect, StatusCode}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use wiremock::matchers::{method, path}; + +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) +} + +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(); + let metadata = json!({ + "client_id": client_id, + "client_name": "Test OAuth 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, + refresh_token: String, + did: String, + client_id: String, +} + +async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &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 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 + .expect("PAR failed"); + + assert_eq!(par_res.status(), StatusCode::OK); + 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 + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("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(); + 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, + }; + + (session, mock_client) +} + +#[tokio::test] +async fn test_oauth_token_can_create_and_read_records() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-records", + "https://example.com/callback" + ).await; + + let collection = "app.bsky.feed.post"; + let post_text = "Hello from OAuth! This post was created with an OAuth access token."; + + 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_text, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .expect("createRecord failed"); + + assert_eq!(create_res.status(), StatusCode::OK, "Should create record with OAuth token"); + + let create_body: Value = create_res.json().await.unwrap(); + let uri = create_body["uri"].as_str().unwrap(); + let rkey = uri.split('/').last().unwrap(); + + let get_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) + .bearer_auth(&session.access_token) + .query(&[ + ("repo", session.did.as_str()), + ("collection", collection), + ("rkey", rkey), + ]) + .send() + .await + .expect("getRecord failed"); + + assert_eq!(get_res.status(), StatusCode::OK, "Should read record with OAuth token"); + + let get_body: Value = get_res.json().await.unwrap(); + assert_eq!(get_body["value"]["text"], post_text); +} + +#[tokio::test] +async fn test_oauth_token_can_upload_blob() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-blob", + "https://example.com/callback" + ).await; + + let blob_data = b"This is test blob data uploaded via OAuth"; + + 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 + .expect("uploadBlob failed"); + + assert_eq!(upload_res.status(), StatusCode::OK, "Should upload blob with OAuth token"); + + let upload_body: Value = upload_res.json().await.unwrap(); + assert!(upload_body["blob"]["ref"]["$link"].is_string()); + assert_eq!(upload_body["blob"]["mimeType"], "text/plain"); +} + +#[tokio::test] +async fn test_oauth_token_can_describe_repo() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-describe", + "https://example.com/callback" + ).await; + + let describe_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url)) + .bearer_auth(&session.access_token) + .query(&[("repo", session.did.as_str())]) + .send() + .await + .expect("describeRepo failed"); + + assert_eq!(describe_res.status(), StatusCode::OK, "Should describe repo with OAuth token"); + + let describe_body: Value = describe_res.json().await.unwrap(); + assert_eq!(describe_body["did"], session.did); + assert!(describe_body["handle"].is_string()); +} + +#[tokio::test] +async fn test_oauth_full_post_lifecycle_create_edit_delete() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-lifecycle", + "https://example.com/callback" + ).await; + + let collection = "app.bsky.feed.post"; + let original_text = "Original post content"; + + 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": original_text, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let uri = create_body["uri"].as_str().unwrap(); + let rkey = uri.split('/').last().unwrap(); + + let updated_text = "Updated post content via OAuth putRecord"; + + 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_text, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(put_res.status(), StatusCode::OK, "Should update record with OAuth token"); + + let get_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) + .bearer_auth(&session.access_token) + .query(&[ + ("repo", session.did.as_str()), + ("collection", collection), + ("rkey", rkey), + ]) + .send() + .await + .unwrap(); + + let get_body: Value = get_res.json().await.unwrap(); + assert_eq!(get_body["value"]["text"], updated_text, "Record should have updated text"); + + 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, "Should delete record with OAuth token"); + + let get_deleted_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) + .bearer_auth(&session.access_token) + .query(&[ + ("repo", session.did.as_str()), + ("collection", collection), + ("rkey", rkey), + ]) + .send() + .await + .unwrap(); + + assert!( + get_deleted_res.status() == StatusCode::BAD_REQUEST || get_deleted_res.status() == StatusCode::NOT_FOUND, + "Deleted record should not be found, got {}", + get_deleted_res.status() + ); +} + +#[tokio::test] +async fn test_oauth_batch_operations_apply_writes() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-batch", + "https://example.com/callback" + ).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": "batch1", + "value": { + "$type": collection, + "text": "Batch post 1", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": collection, + "rkey": "batch2", + "value": { + "$type": collection, + "text": "Batch post 2", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": collection, + "rkey": "batch3", + "value": { + "$type": collection, + "text": "Batch post 3", + "createdAt": now + } + } + ] + })) + .send() + .await + .unwrap(); + + assert_eq!(apply_res.status(), StatusCode::OK, "Should apply batch writes with OAuth token"); + + let list_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&session.access_token) + .query(&[ + ("repo", session.did.as_str()), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + let records = list_body["records"].as_array().unwrap(); + assert!(records.len() >= 3, "Should have at least 3 records from batch"); +} + +#[tokio::test] +async fn test_oauth_token_refresh_maintains_access() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-refresh-access", + "https://example.com/callback" + ).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 before refresh", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res.status(), StatusCode::OK, "Original token should work"); + + let refresh_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &session.refresh_token), + ("client_id", &session.client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(refresh_res.status(), StatusCode::OK); + let refresh_body: Value = refresh_res.json().await.unwrap(); + let new_access_token = refresh_body["access_token"].as_str().unwrap(); + + assert_ne!(new_access_token, session.access_token, "New token should be different"); + + let create_res2 = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(new_access_token) + .json(&json!({ + "repo": session.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Post after refresh with new token", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res2.status(), StatusCode::OK, "New token should work for creating records"); + + let list_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(new_access_token) + .query(&[ + ("repo", session.did.as_str()), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + assert_eq!(list_res.status(), StatusCode::OK, "New token should work for listing records"); + let list_body: Value = list_res.json().await.unwrap(); + let records = list_body["records"].as_array().unwrap(); + assert_eq!(records.len(), 2, "Should have both posts"); +} + +#[tokio::test] +async fn test_oauth_revoked_token_cannot_access_resources() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-revoke-access", + "https://example.com/callback" + ).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 before revocation", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res.status(), StatusCode::OK, "Token should work before revocation"); + + let revoke_res = http_client + .post(format!("{}/oauth/revoke", url)) + .form(&[("token", session.refresh_token.as_str())]) + .send() + .await + .unwrap(); + + assert_eq!(revoke_res.status(), StatusCode::OK, "Revocation should succeed"); + + let refresh_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &session.refresh_token), + ("client_id", &session.client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(refresh_res.status(), StatusCode::BAD_REQUEST, "Revoked refresh token should not work"); +} + +#[tokio::test] +async fn test_oauth_multiple_clients_same_user() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("multi-client-{}", ts); + let email = format!("multi-client-{}@example.com", ts); + let password = "multi-client-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(); + + let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await; + let client1_id = mock_client1.uri(); + + let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await; + let client2_id = mock_client2.uri(); + + let (verifier1, challenge1) = generate_pkce(); + let par_res1 = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client1_id), + ("redirect_uri", "https://client1.example.com/callback"), + ("code_challenge", &challenge1), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .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 + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("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(); + let code1 = location1.split("code=").nth(1).unwrap().split('&').next().unwrap(); + + let token_res1 = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code1), + ("redirect_uri", "https://client1.example.com/callback"), + ("code_verifier", &verifier1), + ("client_id", &client1_id), + ]) + .send() + .await + .unwrap(); + let token_body1: Value = token_res1.json().await.unwrap(); + let token1 = token_body1["access_token"].as_str().unwrap(); + + let (verifier2, challenge2) = generate_pkce(); + let par_res2 = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client2_id), + ("redirect_uri", "https://client2.example.com/callback"), + ("code_challenge", &challenge2), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .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 + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("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(); + let code2 = location2.split("code=").nth(1).unwrap().split('&').next().unwrap(); + + let token_res2 = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code2), + ("redirect_uri", "https://client2.example.com/callback"), + ("code_verifier", &verifier2), + ("client_id", &client2_id), + ]) + .send() + .await + .unwrap(); + let token_body2: Value = token_res2.json().await.unwrap(); + let token2 = token_body2["access_token"].as_str().unwrap(); + + assert_ne!(token1, token2, "Different clients should get different tokens"); + + let collection = "app.bsky.feed.post"; + + let create_res1 = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(token1) + .json(&json!({ + "repo": user_did, + "collection": collection, + "record": { + "$type": collection, + "text": "Post from client 1", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res1.status(), StatusCode::OK, "Client 1 token should work"); + + let create_res2 = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(token2) + .json(&json!({ + "repo": user_did, + "collection": collection, + "record": { + "$type": collection, + "text": "Post from client 2", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(create_res2.status(), StatusCode::OK, "Client 2 token should work"); + + let list_res = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(token1) + .query(&[ + ("repo", user_did), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + let list_body: Value = list_res.json().await.unwrap(); + let records = list_body["records"].as_array().unwrap(); + assert_eq!(records.len(), 2, "Both posts should be visible to either client"); +} + +#[tokio::test] +async fn test_oauth_social_interactions_follow_like_repost() { + let url = base_url().await; + let http_client = client(); + + let (alice, _mock_alice) = create_user_and_oauth_session( + "alice-social", + "https://alice-app.example.com/callback" + ).await; + + let (bob, _mock_bob) = create_user_and_oauth_session( + "bob-social", + "https://bob-app.example.com/callback" + ).await; + + let post_collection = "app.bsky.feed.post"; + let post_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&alice.access_token) + .json(&json!({ + "repo": alice.did, + "collection": post_collection, + "record": { + "$type": post_collection, + "text": "Hello from Alice! Looking for friends.", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(post_res.status(), StatusCode::OK); + let post_body: Value = post_res.json().await.unwrap(); + let post_uri = post_body["uri"].as_str().unwrap(); + let post_cid = post_body["cid"].as_str().unwrap(); + + let follow_collection = "app.bsky.graph.follow"; + let follow_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&bob.access_token) + .json(&json!({ + "repo": bob.did, + "collection": follow_collection, + "record": { + "$type": follow_collection, + "subject": alice.did, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(follow_res.status(), StatusCode::OK, "Bob should be able to follow Alice via OAuth"); + + let like_collection = "app.bsky.feed.like"; + let like_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&bob.access_token) + .json(&json!({ + "repo": bob.did, + "collection": like_collection, + "record": { + "$type": like_collection, + "subject": { + "uri": post_uri, + "cid": post_cid + }, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(like_res.status(), StatusCode::OK, "Bob should be able to like Alice's post via OAuth"); + + let repost_collection = "app.bsky.feed.repost"; + let repost_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&bob.access_token) + .json(&json!({ + "repo": bob.did, + "collection": repost_collection, + "record": { + "$type": repost_collection, + "subject": { + "uri": post_uri, + "cid": post_cid + }, + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(repost_res.status(), StatusCode::OK, "Bob should be able to repost Alice's post via OAuth"); + + let bob_follows = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&bob.access_token) + .query(&[ + ("repo", bob.did.as_str()), + ("collection", follow_collection), + ]) + .send() + .await + .unwrap(); + + let follows_body: Value = bob_follows.json().await.unwrap(); + let follows = follows_body["records"].as_array().unwrap(); + assert_eq!(follows.len(), 1, "Bob should have 1 follow"); + assert_eq!(follows[0]["value"]["subject"], alice.did); + + let bob_likes = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&bob.access_token) + .query(&[ + ("repo", bob.did.as_str()), + ("collection", like_collection), + ]) + .send() + .await + .unwrap(); + + let likes_body: Value = bob_likes.json().await.unwrap(); + let likes = likes_body["records"].as_array().unwrap(); + assert_eq!(likes.len(), 1, "Bob should have 1 like"); +} + +#[tokio::test] +async fn test_oauth_cannot_modify_other_users_repo() { + let url = base_url().await; + let http_client = client(); + + let (alice, _mock_alice) = create_user_and_oauth_session( + "alice-boundary", + "https://alice.example.com/callback" + ).await; + + let (bob, _mock_bob) = create_user_and_oauth_session( + "bob-boundary", + "https://bob.example.com/callback" + ).await; + + let collection = "app.bsky.feed.post"; + let malicious_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&bob.access_token) + .json(&json!({ + "repo": alice.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Bob trying to post as Alice!", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_ne!( + malicious_res.status(), + StatusCode::OK, + "Bob should NOT be able to create records in Alice's repo" + ); + + let alice_posts = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&alice.access_token) + .query(&[ + ("repo", alice.did.as_str()), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + let posts_body: Value = alice_posts.json().await.unwrap(); + let posts = posts_body["records"].as_array().unwrap(); + assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob"); +} + +#[tokio::test] +async fn test_oauth_session_isolation_between_users() { + let url = base_url().await; + let http_client = client(); + + let (alice, _mock_alice) = create_user_and_oauth_session( + "alice-isolation", + "https://alice.example.com/callback" + ).await; + + let (bob, _mock_bob) = create_user_and_oauth_session( + "bob-isolation", + "https://bob.example.com/callback" + ).await; + + let collection = "app.bsky.feed.post"; + + let alice_post = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&alice.access_token) + .json(&json!({ + "repo": alice.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Alice's private thoughts", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(alice_post.status(), StatusCode::OK); + + let bob_post = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&bob.access_token) + .json(&json!({ + "repo": bob.did, + "collection": collection, + "record": { + "$type": collection, + "text": "Bob's different thoughts", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + assert_eq!(bob_post.status(), StatusCode::OK); + + let alice_list = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&alice.access_token) + .query(&[ + ("repo", alice.did.as_str()), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + let alice_records: Value = alice_list.json().await.unwrap(); + let alice_posts = alice_records["records"].as_array().unwrap(); + assert_eq!(alice_posts.len(), 1); + assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts"); + + let bob_list = http_client + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) + .bearer_auth(&bob.access_token) + .query(&[ + ("repo", bob.did.as_str()), + ("collection", collection), + ]) + .send() + .await + .unwrap(); + + let bob_records: Value = bob_list.json().await.unwrap(); + let bob_posts = bob_records["records"].as_array().unwrap(); + assert_eq!(bob_posts.len(), 1); + assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts"); +} + +#[tokio::test] +async fn test_oauth_token_works_with_sync_endpoints() { + let url = base_url().await; + let http_client = client(); + + let (session, _mock) = create_user_and_oauth_session( + "oauth-sync", + "https://example.com/callback" + ).await; + + let collection = "app.bsky.feed.post"; + 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 to sync", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .unwrap(); + + let latest_commit = http_client + .get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url)) + .query(&[("did", session.did.as_str())]) + .send() + .await + .unwrap(); + + assert_eq!(latest_commit.status(), StatusCode::OK); + let commit_body: Value = latest_commit.json().await.unwrap(); + assert!(commit_body["cid"].is_string()); + assert!(commit_body["rev"].is_string()); + + let repo_status = http_client + .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url)) + .query(&[("did", session.did.as_str())]) + .send() + .await + .unwrap(); + + assert_eq!(repo_status.status(), StatusCode::OK); + let status_body: Value = repo_status.json().await.unwrap(); + assert_eq!(status_body["did"], session.did); + assert!(status_body["active"].as_bool().unwrap()); +} diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs new file mode 100644 index 0000000..e240cea --- /dev/null +++ b/tests/oauth_security.rs @@ -0,0 +1,1448 @@ +#![allow(unused_imports)] +#![allow(unused_variables)] + +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint}; +use chrono::Utc; +use common::{base_url, client}; +use reqwest::{redirect, StatusCode}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use wiremock::matchers::{method, path}; + +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); + + 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": "Security Test 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 +} + +async fn get_oauth_tokens( + http_client: &reqwest::Client, + url: &str, +) -> (String, String, String) { + let ts = Utc::now().timestamp_millis(); + let handle = format!("sec-test-{}", ts); + let email = format!("sec-test-{}@example.com", ts); + let password = "security-test-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/sec-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(); + + 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", 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(); + + let access_token = token_body["access_token"].as_str().unwrap().to_string(); + let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); + + (access_token, refresh_token, client_id) +} + +#[tokio::test] +async fn test_security_forged_token_signature_rejected() { + let url = base_url().await; + let http_client = client(); + + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; + + let parts: Vec<&str> = access_token.split('.').collect(); + assert_eq!(parts.len(), 3, "Token should have 3 parts"); + + let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 32]); + let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature); + + 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 signature should be rejected"); +} + +#[tokio::test] +async fn test_security_modified_payload_rejected() { + let url = base_url().await; + let http_client = client(); + + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; + + let parts: Vec<&str> = access_token.split('.').collect(); + + 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]); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", modified_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected"); +} + +#[tokio::test] +async fn test_security_algorithm_none_attack_rejected() { + let url = base_url().await; + let http_client = client(); + + let header = json!({ + "alg": "none", + "typ": "at+jwt" + }); + let 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-token-id", + "scope": "atproto" + }); + + 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 malicious_token = format!("{}.{}.", header_b64, payload_b64); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", malicious_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm 'none' attack should be rejected"); +} + +#[tokio::test] +async fn test_security_algorithm_substitution_attack_rejected() { + let url = base_url().await; + let http_client = client(); + + let header = json!({ + "alg": "RS256", + "typ": "at+jwt" + }); + let 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-token-id" + }); + + 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 fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); + let malicious_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", malicious_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm substitution attack should be rejected"); +} + +#[tokio::test] +async fn test_security_expired_token_rejected() { + let url = base_url().await; + let http_client = client(); + + let header = json!({ + "alg": "HS256", + "typ": "at+jwt" + }); + let 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-token-id" + }); + + 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 fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); + let expired_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", expired_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected"); +} + +#[tokio::test] +async fn test_security_pkce_plain_method_rejected() { + let url = base_url().await; + let http_client = client(); + + let redirect_uri = "https://example.com/pkce-plain-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 body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_request"); + assert!( + body["error_description"].as_str().unwrap().to_lowercase().contains("s256"), + "Error should mention S256 requirement" + ); +} + +#[tokio::test] +async fn test_security_pkce_missing_challenge_rejected() { + let url = base_url().await; + let http_client = client(); + + let redirect_uri = "https://example.com/no-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), + ]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); +} + +#[tokio::test] +async fn test_security_pkce_wrong_verifier_rejected() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("pkce-attack-{}", ts); + let email = format!("pkce-attack-{}@example.com", ts); + let password = "pkce-attack-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/pkce-attack-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + + 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 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", 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"); + let body: Value = token_res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_grant"); +} + +#[tokio::test] +async fn test_security_authorization_code_replay_attack() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("code-replay-{}", ts); + let email = format!("code-replay-{}@example.com", ts); + let password = "code-replay-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/code-replay-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(); + + 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", 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 stolen_code = code.to_string(); + + let first_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!(first_res.status(), StatusCode::OK, "First use should succeed"); + + let replay_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &stolen_code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Replay attack should fail"); + let body: Value = replay_res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_grant"); +} + +#[tokio::test] +async fn test_security_refresh_token_replay_attack() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("rt-replay-{}", ts); + let email = format!("rt-replay-{}@example.com", ts); + let password = "rt-replay-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/rt-replay-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(); + + 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", 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(); + + let stolen_refresh_token = token_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_refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); + let new_refresh_token = first_refresh["refresh_token"].as_str().unwrap(); + + let replay_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &stolen_refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail"); + let body: Value = replay_res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_grant"); + assert!( + body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"), + "Error should mention token reuse" + ); + + let family_revoked_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", new_refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + assert_eq!( + family_revoked_res.status(), + StatusCode::BAD_REQUEST, + "Token family should be revoked after replay detection" + ); +} + +#[tokio::test] +async fn test_security_redirect_uri_manipulation() { + let url = base_url().await; + let http_client = client(); + + let registered_redirect = "https://legitimate-app.com/callback"; + let attacker_redirect = "https://attacker.com/steal"; + 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", attacker_redirect), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected"); +} + +#[tokio::test] +async fn test_security_deactivated_account_blocked() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("deact-sec-{}", ts); + let email = format!("deact-sec-{}@example.com", ts); + let password = "deact-sec-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 access_jwt = account["accessJwt"].as_str().unwrap(); + + let deact_res = http_client + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) + .header("Authorization", format!("Bearer {}", access_jwt)) + .json(&json!({})) + .send() + .await + .unwrap(); + assert_eq!(deact_res.status(), StatusCode::OK); + + let redirect_uri = "https://example.com/deact-sec-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(); + + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("request_uri", request_uri), + ("username", &handle), + ("password", password), + ("remember_device", "false"), + ]) + .send() + .await + .unwrap(); + + assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth"); + let body: Value = auth_res.json().await.unwrap(); + assert_eq!(body["error"], "access_denied"); +} + +#[tokio::test] +async fn test_security_url_injection_in_state_parameter() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("inject-state-{}", ts); + let email = format!("inject-state-{}@example.com", ts); + let password = "inject-state-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri = "https://example.com/inject-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 malicious_state = "state&redirect_uri=https://attacker.com&extra="; + + 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", malicious_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 + .post(format!("{}/oauth/authorize", url)) + .form(&[ + ("request_uri", request_uri), + ("username", &handle), + ("password", password), + ("remember_device", "false"), + ]) + .send() + .await + .unwrap(); + + assert!(auth_res.status().is_redirection(), "Should redirect successfully"); + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); + + assert!( + location.starts_with(redirect_uri), + "Redirect should go to registered URI, not attacker URI. Got: {}", + location + ); + + let redirect_uri_count = location.matches("redirect_uri=").count(); + assert!( + redirect_uri_count <= 1, + "State injection should not add extra redirect_uri parameters" + ); + + assert!( + location.contains(&urlencoding::encode(malicious_state).to_string()) || + location.contains("state=state%26redirect_uri"), + "State parameter should be properly URL-encoded. Got: {}", + location + ); +} + +#[tokio::test] +async fn test_security_cross_client_token_theft() { + let url = base_url().await; + let http_client = client(); + + let ts = Utc::now().timestamp_millis(); + let handle = format!("cross-client-{}", ts); + let email = format!("cross-client-{}@example.com", ts); + let password = "cross-client-password"; + + http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + + let redirect_uri_a = "https://app-a.com/callback"; + let mock_client_a = setup_mock_client_metadata(redirect_uri_a).await; + let client_id_a = mock_client_a.uri(); + + let redirect_uri_b = "https://app-b.com/callback"; + let mock_client_b = setup_mock_client_metadata(redirect_uri_b).await; + let client_id_b = mock_client_b.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_a), + ("redirect_uri", redirect_uri_a), + ("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", 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_a), + ("code_verifier", &code_verifier), + ("client_id", &client_id_b), + ]) + .send() + .await + .unwrap(); + + assert_eq!( + token_res.status(), + StatusCode::BAD_REQUEST, + "Cross-client code exchange must be explicitly rejected (defense-in-depth)" + ); + let body: Value = token_res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_grant"); + assert!( + body["error_description"].as_str().unwrap().contains("client_id"), + "Error should mention client_id mismatch" + ); +} + +#[test] +fn test_security_dpop_nonce_tamper_detection() { + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let nonce = verifier.generate_nonce(); + let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); + + let mut tampered = nonce_bytes.clone(); + if !tampered.is_empty() { + tampered[0] ^= 0xFF; + } + let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered); + + let result = verifier.validate_nonce(&tampered_nonce); + assert!(result.is_err(), "Tampered nonce should be rejected"); +} + +#[test] +fn test_security_dpop_nonce_cross_server_rejected() { + let secret1 = b"server-1-secret-32-bytes-long!!!"; + let secret2 = b"server-2-secret-32-bytes-long!!!"; + + let verifier1 = DPoPVerifier::new(secret1); + let verifier2 = DPoPVerifier::new(secret2); + + let nonce_from_server1 = verifier1.generate_nonce(); + + let result = verifier2.validate_nonce(&nonce_from_server1); + assert!(result.is_err(), "Nonce from different server should be rejected"); +} + +#[test] +fn test_security_dpop_proof_signature_tampering() { + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": format!("tamper-test-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), + "htm": "POST", + "htu": "https://example.com/token", + "iat": Utc::now().timestamp() + }); + + 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()); + let mut sig_bytes = signature.to_bytes().to_vec(); + + sig_bytes[0] ^= 0xFF; + let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes); + + let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig); + + let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None); + assert!(result.is_err(), "Tampered DPoP signature should be rejected"); +} + +#[test] +fn test_security_dpop_proof_key_substitution() { + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + + let attacker_key = SigningKey::random(&mut rand::thread_rng()); + let attacker_verifying = attacker_key.verifying_key(); + let attacker_point = attacker_verifying.to_encoded_point(false); + + let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), + "htm": "POST", + "htu": "https://example.com/token", + "iat": Utc::now().timestamp() + }); + + 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()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); + + let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None); + assert!(result.is_err(), "DPoP proof with mismatched key should be rejected"); +} + +#[test] +fn test_security_jwk_thumbprint_consistency() { + 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()), + }; + + let mut results = Vec::new(); + for _ in 0..100 { + results.push(compute_jwk_thumbprint(&jwk).unwrap()); + } + + let first = &results[0]; + for (i, result) in results.iter().enumerate() { + assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i); + } +} + +#[test] +fn test_security_dpop_iat_clock_skew_limits() { + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let test_offsets = vec![ + (-600, true), + (-301, true), + (-299, false), + (0, false), + (299, false), + (301, true), + (600, true), + ]; + + for (offset_secs, should_fail) in test_offsets { + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": format!("clock-{}-{}", offset_secs, Utc::now().timestamp_nanos_opt().unwrap_or(0)), + "htm": "POST", + "htu": "https://example.com/token", + "iat": Utc::now().timestamp() + offset_secs + }); + + 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()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); + + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); + + if should_fail { + assert!(result.is_err(), "iat offset {} should be rejected", offset_secs); + } else { + assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs); + } + } +} + +#[test] +fn test_security_dpop_method_case_insensitivity() { + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + let secret = b"test-dpop-secret-32-bytes-long!!"; + let verifier = DPoPVerifier::new(secret); + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let header = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + } + }); + + let payload = json!({ + "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), + "htm": "post", + "htu": "https://example.com/token", + "iat": Utc::now().timestamp() + }); + + 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()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); + + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); + assert!(result.is_ok(), "HTTP method comparison should be case-insensitive"); +} + +#[tokio::test] +async fn test_security_invalid_grant_type_rejected() { + let url = base_url().await; + let http_client = client(); + + let grant_types = vec![ + "client_credentials", + "password", + "implicit", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:device_code", + "", + "AUTHORIZATION_CODE", + "Authorization_Code", + ]; + + for grant_type in grant_types { + let res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", grant_type), + ("client_id", "https://example.com"), + ]) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Grant type '{}' should be rejected", + grant_type + ); + } +} + +#[tokio::test] +async fn test_security_token_with_wrong_typ_rejected() { + let url = base_url().await; + let http_client = client(); + + let wrong_types = vec![ + "JWT", + "jwt", + "at+JWT", + "access_token", + "", + ]; + + for typ in wrong_types { + let header = json!({ + "alg": "HS256", + "typ": typ + }); + let payload = json!({ + "iss": "https://test.pds", + "sub": "did:plc:test", + "aud": "https://test.pds", + "iat": Utc::now().timestamp(), + "exp": Utc::now().timestamp() + 3600, + "jti": "wrong-typ-token" + }); + + 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 fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); + let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Token with typ='{}' should be rejected", + typ + ); + } +} + +#[tokio::test] +async fn test_security_missing_required_claims_rejected() { + let url = base_url().await; + let http_client = client(); + + let tokens_missing_claims = vec![ + (json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"), + (json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"), + (json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"), + ]; + + for (payload, missing_claim) in tokens_missing_claims { + let header = json!({ + "alg": "HS256", + "typ": "at+jwt" + }); + + 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 fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); + let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Token missing '{}' claim should be rejected", + missing_claim + ); + } +} + +#[tokio::test] +async fn test_security_malformed_tokens_rejected() { + let url = base_url().await; + let http_client = client(); + + let malformed_tokens = vec![ + "", + "not-a-token", + "one.two", + "one.two.three.four", + "....", + "eyJhbGciOiJIUzI1NiJ9", + "eyJhbGciOiJIUzI1NiJ9.", + "eyJhbGciOiJIUzI1NiJ9..", + ".eyJzdWIiOiJ0ZXN0In0.", + "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig", + "eyJhbGciOiJIUzI1NiJ9.!!invalid!!.sig", + ]; + + for token in malformed_tokens { + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Malformed token '{}' should be rejected", + if token.len() > 50 { &token[..50] } else { token } + ); + } +} + +#[tokio::test] +async fn test_security_authorization_header_formats() { + let url = base_url().await; + let http_client = client(); + + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; + + let valid_case_variants = vec![ + format!("bearer {}", access_token), + format!("BEARER {}", access_token), + format!("Bearer {}", access_token), + ]; + + for auth_header in valid_case_variants { + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", &auth_header) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::OK, + "Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)", + if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } + ); + } + + let invalid_formats = vec![ + format!("Basic {}", access_token), + format!("Digest {}", access_token), + access_token.clone(), + format!("Bearer{}", access_token), + ]; + + for auth_header in invalid_formats { + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", &auth_header) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Auth header '{}...' should be rejected", + if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } + ); + } +} + +#[tokio::test] +async fn test_security_no_authorization_header() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401"); +} + +#[tokio::test] +async fn test_security_empty_authorization_header() { + let url = base_url().await; + let http_client = client(); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", "") + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401"); +} + +#[tokio::test] +async fn test_security_revoked_token_rejected() { + let url = base_url().await; + let http_client = client(); + + let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await; + + let revoke_res = http_client + .post(format!("{}/oauth/revoke", url)) + .form(&[("token", &refresh_token)]) + .send() + .await + .unwrap(); + + assert_eq!(revoke_res.status(), StatusCode::OK); + + let introspect_res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", &access_token)]) + .send() + .await + .unwrap(); + + let introspect_body: Value = introspect_res.json().await.unwrap(); + assert_eq!(introspect_body["active"], false, "Revoked token should be inactive"); +}