PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

Initial oauth impl

Changed files
+12090 -1746
.config
.sqlx
migrations
scripts
src
tests
+12
.config/nextest.toml
··· 1 + [store] 2 + dir = "target/nextest" 3 + 4 + [profile.default] 5 + retries = 0 6 + fail-fast = true 7 + test-threads = "num-cpus" 8 + 9 + [profile.ci] 10 + retries = 2 11 + fail-fast = false 12 + test-threads = "num-cpus"
+18
.sqlx/query-0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Timestamptz", 12 + "Timestamptz" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3" 18 + }
+15
.sqlx/query-03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262" 15 + }
-40
.sqlx/query-09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "handle", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "key_bytes", 24 - "type_info": "Bytea" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - false, 36 - false 37 - ] 38 - }, 39 - "hash": "09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e" 40 - }
+15
.sqlx/query-0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int8" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52" 15 + }
+14
.sqlx/query-0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_device\n SET last_seen_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78" 14 + }
+22
.sqlx/query-0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT COUNT(*) as \"count!\" FROM oauth_token WHERE did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count!", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb" 22 + }
+28
.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT handle, email FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a" 28 + }
+23
.sqlx/query-1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT data FROM oauth_authorized_client\n WHERE did = $1 AND client_id = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "data", 9 + "type_info": "Jsonb" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587" 23 + }
+9 -3
.sqlx/query-2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601.json .sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 27 27 "ordinal": 4, 28 28 "name": "key_bytes", 29 29 "type_info": "Bytea" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "encryption_version", 34 + "type_info": "Int4" 30 35 } 31 36 ], 32 37 "parameters": { ··· 39 44 false, 40 45 false, 41 46 false, 42 - false 47 + false, 48 + true 43 49 ] 44 50 }, 45 - "hash": "2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601" 51 + "hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb" 46 52 }
+14
.sqlx/query-235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_device WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766" 14 + }
+17
.sqlx/query-26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, code = $4\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Text" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533" 17 + }
+15
.sqlx/query-2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO oauth_used_refresh_token (refresh_token, token_id)\n VALUES ($1, $2)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int4" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc" 15 + }
+14
.sqlx/query-31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM session_tokens WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09" 14 + }
-52
.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "user_id", 19 - "type_info": "Uuid" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "email_confirmation_code", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_confirmation_code_expires_at", 29 - "type_info": "Timestamptz" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "email_pending_verification", 34 - "type_info": "Text" 35 - } 36 - ], 37 - "parameters": { 38 - "Left": [ 39 - "Text" 40 - ] 41 - }, 42 - "nullable": [ 43 - false, 44 - false, 45 - false, 46 - true, 47 - true, 48 - true 49 - ] 50 - }, 51 - "hash": "3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d" 52 - }
+40
.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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()", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "key_bytes", 19 + "type_info": "Bytea" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "encryption_version", 24 + "type_info": "Int4" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false, 36 + true 37 + ] 38 + }, 39 + "hash": "3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817" 40 + }
+14
.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd" 14 + }
-34
.sqlx/query-47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "handle", 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - false 31 - ] 32 - }, 33 - "hash": "47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf" 34 - }
-28
.sqlx/query-48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416" 28 - }
+18
.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Timestamptz", 11 + "Timestamptz", 12 + "Int4" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234" 18 + }
-14
.sqlx/query-52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM sessions WHERE access_jwt = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89" 14 - }
+21
.sqlx/query-52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Text", 12 + "Jsonb", 13 + "Jsonb", 14 + "Timestamptz", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [] 19 + }, 20 + "hash": "52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550" 21 + }
+94
.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "token_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "created_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "updated_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "expires_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "client_id", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "client_auth", 39 + "type_info": "Jsonb" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "device_id", 44 + "type_info": "Text" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "parameters", 49 + "type_info": "Jsonb" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "details", 54 + "type_info": "Jsonb" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "code", 59 + "type_info": "Text" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "current_refresh_token", 64 + "type_info": "Text" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "scope", 69 + "type_info": "Text" 70 + } 71 + ], 72 + "parameters": { 73 + "Left": [ 74 + "Text" 75 + ] 76 + }, 77 + "nullable": [ 78 + false, 79 + false, 80 + false, 81 + false, 82 + false, 83 + false, 84 + false, 85 + true, 86 + false, 87 + true, 88 + true, 89 + true, 90 + true 91 + ] 92 + }, 93 + "hash": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765" 94 + }
-46
.sqlx/query-55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "user_id", 14 - "type_info": "Uuid" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "handle", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "key_bytes", 29 - "type_info": "Bytea" 30 - } 31 - ], 32 - "parameters": { 33 - "Left": [ 34 - "Text" 35 - ] 36 - }, 37 - "nullable": [ 38 - false, 39 - false, 40 - false, 41 - false, 42 - false 43 - ] 44 - }, 45 - "hash": "55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275" 46 - }
-40
.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "user_id", 19 - "type_info": "Uuid" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "handle", 24 - "type_info": "Text" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - false, 36 - false 37 - ] 38 - }, 39 - "hash": "6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175" 40 - }
+34
.sqlx/query-6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text", 16 + "Timestamptz", 17 + "Timestamptz", 18 + "Timestamptz", 19 + "Text", 20 + "Jsonb", 21 + "Text", 22 + "Jsonb", 23 + "Jsonb", 24 + "Text", 25 + "Text", 26 + "Text" 27 + ] 28 + }, 29 + "nullable": [ 30 + false 31 + ] 32 + }, 33 + "hash": "6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0" 34 + }
+40
.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "takedown_ref", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + true, 35 + true, 36 + true 37 + ] 38 + }, 39 + "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" 40 + }
+16
.sqlx/query-73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Bytea", 10 + "Int4" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7" 16 + }
+15
.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int4" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd" 15 + }
+14
.sqlx/query-7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO oauth_dpop_jti (jti)\n VALUES ($1)\n ON CONFLICT (jti) DO NOTHING\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974" 14 + }
+28
.sqlx/query-7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92" 28 + }
-58
.sqlx/query-7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "user_id", 19 - "type_info": "Uuid" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "current_email", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_confirmation_code", 29 - "type_info": "Text" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "email_confirmation_code_expires_at", 34 - "type_info": "Timestamptz" 35 - }, 36 - { 37 - "ordinal": 6, 38 - "name": "email_pending_verification", 39 - "type_info": "Text" 40 - } 41 - ], 42 - "parameters": { 43 - "Left": [ 44 - "Text" 45 - ] 46 - }, 47 - "nullable": [ 48 - false, 49 - false, 50 - false, 51 - false, 52 - true, 53 - true, 54 - true 55 - ] 56 - }, 57 - "hash": "7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9" 58 - }
+14
.sqlx/query-847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM session_tokens WHERE access_jti = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6" 14 + }
+40
.sqlx/query-91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT did, password_hash, deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_hash", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "takedown_ref", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + true, 36 + true 37 + ] 38 + }, 39 + "hash": "91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43" 40 + }
+22
.sqlx/query-93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT current_refresh_token FROM oauth_token WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "current_refresh_token", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Int4" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043" 22 + }
-14
.sqlx/query-9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM sessions WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49" 14 - }
-16
.sqlx/query-a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83" 16 - }
+46
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_confirmation_code", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "email_confirmation_code_expires_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "email_pending_verification", 29 + "type_info": "Text" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + true, 41 + true, 42 + true 43 + ] 44 + }, 45 + "hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a" 46 + }
+28
.sqlx/query-b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268" 28 + }
+40
.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email_confirmation_code", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_confirmation_code_expires_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "email_pending_verification", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + true, 35 + true, 36 + true 37 + ] 38 + }, 39 + "hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5" 40 + }
+94
.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "token_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "created_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "updated_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "expires_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "client_id", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "client_auth", 39 + "type_info": "Jsonb" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "device_id", 44 + "type_info": "Text" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "parameters", 49 + "type_info": "Jsonb" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "details", 54 + "type_info": "Jsonb" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "code", 59 + "type_info": "Text" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "current_refresh_token", 64 + "type_info": "Text" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "scope", 69 + "type_info": "Text" 70 + } 71 + ], 72 + "parameters": { 73 + "Left": [ 74 + "Text" 75 + ] 76 + }, 77 + "nullable": [ 78 + false, 79 + false, 80 + false, 81 + false, 82 + false, 83 + false, 84 + false, 85 + true, 86 + false, 87 + true, 88 + true, 89 + true, 90 + true 91 + ] 92 + }, 93 + "hash": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8" 94 + }
+12
.sqlx/query-b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_authorization_request\n WHERE expires_at < NOW()\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [] 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230" 12 + }
+17
.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4", 9 + "Text", 10 + "Text", 11 + "Timestamptz" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284" 17 + }
+14
.sqlx/query-bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_token WHERE token_id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5" 14 + }
+100
.sqlx/query-bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "token_id", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "created_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "updated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "expires_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "client_id", 39 + "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "client_auth", 44 + "type_info": "Jsonb" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "device_id", 49 + "type_info": "Text" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "parameters", 54 + "type_info": "Jsonb" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "details", 59 + "type_info": "Jsonb" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "code", 64 + "type_info": "Text" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "current_refresh_token", 69 + "type_info": "Text" 70 + }, 71 + { 72 + "ordinal": 13, 73 + "name": "scope", 74 + "type_info": "Text" 75 + } 76 + ], 77 + "parameters": { 78 + "Left": [ 79 + "Text" 80 + ] 81 + }, 82 + "nullable": [ 83 + false, 84 + false, 85 + false, 86 + false, 87 + false, 88 + false, 89 + false, 90 + false, 91 + true, 92 + false, 93 + true, 94 + true, 95 + true, 96 + true 97 + ] 98 + }, 99 + "hash": "bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95" 100 + }
+22
.sqlx/query-bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3" 22 + }
-15
.sqlx/query-c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Bytea" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3" 15 - }
+14
.sqlx/query-c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_token WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5" 14 + }
-28
.sqlx/query-c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac" 28 - }
+40
.sqlx/query-c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT session_id, user_agent, ip_address, last_seen_at\n FROM oauth_device\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "session_id", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "user_agent", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "ip_address", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "last_seen_at", 24 + "type_info": "Timestamptz" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + true, 35 + false, 36 + false 37 + ] 38 + }, 39 + "hash": "c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e" 40 + }
+18
.sqlx/query-cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Text", 12 + "Timestamptz" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b" 18 + }
+28
.sqlx/query-cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1" 28 + }
+16
.sqlx/query-cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Jsonb" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789" 16 + }
-22
.sqlx/query-ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT key_bytes FROM user_keys WHERE user_id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "key_bytes", 9 - "type_info": "Bytea" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd" 22 - }
+14
.sqlx/query-cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM session_tokens WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f" 14 + }
-34
.sqlx/query-d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "user_id", 19 - "type_info": "Uuid" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - false 31 - ] 32 - }, 33 - "hash": "d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d" 34 - }
+22
.sqlx/query-d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "token_id", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367" 22 + }
+58
.sqlx/query-d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code\n FROM oauth_authorization_request\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "device_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "client_id", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "client_auth", 24 + "type_info": "Jsonb" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "parameters", 29 + "type_info": "Jsonb" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "expires_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "code", 39 + "type_info": "Text" 40 + } 41 + ], 42 + "parameters": { 43 + "Left": [ 44 + "Text" 45 + ] 46 + }, 47 + "nullable": [ 48 + true, 49 + true, 50 + false, 51 + true, 52 + false, 53 + false, 54 + true 55 + ] 56 + }, 57 + "hash": "d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3" 58 + }
+23
.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "one", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + null 20 + ] 21 + }, 22 + "hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7" 23 + }
-16
.sqlx/query-db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64" 16 - }
+58
.sqlx/query-df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "device_id", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "client_id", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "client_auth", 24 + "type_info": "Jsonb" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "parameters", 29 + "type_info": "Jsonb" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "expires_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "code", 39 + "type_info": "Text" 40 + } 41 + ], 42 + "parameters": { 43 + "Left": [ 44 + "Text" 45 + ] 46 + }, 47 + "nullable": [ 48 + true, 49 + true, 50 + false, 51 + true, 52 + false, 53 + false, 54 + true 55 + ] 56 + }, 57 + "hash": "df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4" 58 + }
-22
.sqlx/query-ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "key_bytes", 9 - "type_info": "Bytea" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee" 22 - }
+40
.sqlx/query-efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "takedown_ref", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + true, 36 + true 37 + ] 38 + }, 39 + "hash": "efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07" 40 + }
+14
.sqlx/query-f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_dpop_jti\n WHERE created_at < NOW() - INTERVAL '1 second' * $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Float8" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e" 14 + }
+14
.sqlx/query-f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n DELETE FROM oauth_authorization_request WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce" 14 + }
-28
.sqlx/query-f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "key_bytes", 14 - "type_info": "Bytea" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14" 28 - }
+22
.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "session_id", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3" 22 + }
-14
.sqlx/query-fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535" 14 - }
+91
Cargo.lock
··· 28 28 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 29 29 30 30 [[package]] 31 + name = "aead" 32 + version = "0.5.2" 33 + source = "registry+https://github.com/rust-lang/crates.io-index" 34 + checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" 35 + dependencies = [ 36 + "crypto-common", 37 + "generic-array", 38 + ] 39 + 40 + [[package]] 41 + name = "aes" 42 + version = "0.8.4" 43 + source = "registry+https://github.com/rust-lang/crates.io-index" 44 + checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" 45 + dependencies = [ 46 + "cfg-if", 47 + "cipher", 48 + "cpufeatures", 49 + ] 50 + 51 + [[package]] 52 + name = "aes-gcm" 53 + version = "0.10.3" 54 + source = "registry+https://github.com/rust-lang/crates.io-index" 55 + checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" 56 + dependencies = [ 57 + "aead", 58 + "aes", 59 + "cipher", 60 + "ctr", 61 + "ghash", 62 + "subtle", 63 + ] 64 + 65 + [[package]] 31 66 name = "aho-corasick" 32 67 version = "1.1.4" 33 68 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 865 900 name = "bspds" 866 901 version = "0.1.0" 867 902 dependencies = [ 903 + "aes-gcm", 868 904 "anyhow", 869 905 "async-trait", 870 906 "aws-config", ··· 877 913 "cid", 878 914 "ctor", 879 915 "dotenvy", 916 + "ed25519-dalek", 880 917 "futures", 918 + "hkdf", 919 + "hmac", 881 920 "iroh-car", 882 921 "jacquard", 883 922 "jacquard-axum", ··· 886 925 "k256", 887 926 "multibase", 888 927 "multihash", 928 + "p256 0.13.2", 929 + "p384", 889 930 "rand 0.8.5", 890 931 "reqwest", 891 932 "serde", ··· 894 935 "serde_json", 895 936 "sha2", 896 937 "sqlx", 938 + "subtle", 897 939 "testcontainers", 898 940 "testcontainers-modules", 899 941 "thiserror 2.0.17", ··· 901 943 "tokio-tungstenite", 902 944 "tracing", 903 945 "tracing-subscriber", 946 + "urlencoding", 904 947 "uuid", 905 948 "wiremock", 906 949 ] ··· 1303 1346 checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 1304 1347 dependencies = [ 1305 1348 "generic-array", 1349 + "rand_core 0.6.4", 1306 1350 "typenum", 1307 1351 ] 1308 1352 ··· 1323 1367 checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" 1324 1368 1325 1369 [[package]] 1370 + name = "ctr" 1371 + version = "0.9.2" 1372 + source = "registry+https://github.com/rust-lang/crates.io-index" 1373 + checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" 1374 + dependencies = [ 1375 + "cipher", 1376 + ] 1377 + 1378 + [[package]] 1326 1379 name = "curve25519-dalek" 1327 1380 version = "4.1.3" 1328 1381 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2069 2122 "r-efi", 2070 2123 "wasip2", 2071 2124 "wasm-bindgen", 2125 + ] 2126 + 2127 + [[package]] 2128 + name = "ghash" 2129 + version = "0.5.1" 2130 + source = "registry+https://github.com/rust-lang/crates.io-index" 2131 + checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" 2132 + dependencies = [ 2133 + "opaque-debug", 2134 + "polyval", 2072 2135 ] 2073 2136 2074 2137 [[package]] ··· 3609 3672 version = "1.21.3" 3610 3673 source = "registry+https://github.com/rust-lang/crates.io-index" 3611 3674 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 3675 + 3676 + [[package]] 3677 + name = "opaque-debug" 3678 + version = "0.3.1" 3679 + source = "registry+https://github.com/rust-lang/crates.io-index" 3680 + checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 3612 3681 3613 3682 [[package]] 3614 3683 name = "openssl" ··· 3906 3975 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 3907 3976 3908 3977 [[package]] 3978 + name = "polyval" 3979 + version = "0.6.2" 3980 + source = "registry+https://github.com/rust-lang/crates.io-index" 3981 + checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" 3982 + dependencies = [ 3983 + "cfg-if", 3984 + "cpufeatures", 3985 + "opaque-debug", 3986 + "universal-hash", 3987 + ] 3988 + 3989 + [[package]] 3909 3990 name = "portable-atomic" 3910 3991 version = "1.11.1" 3911 3992 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5854 5935 version = "0.2.6" 5855 5936 source = "registry+https://github.com/rust-lang/crates.io-index" 5856 5937 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 5938 + 5939 + [[package]] 5940 + name = "universal-hash" 5941 + version = "0.5.1" 5942 + source = "registry+https://github.com/rust-lang/crates.io-index" 5943 + checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" 5944 + dependencies = [ 5945 + "crypto-common", 5946 + "subtle", 5947 + ] 5857 5948 5858 5949 [[package]] 5859 5950 name = "unsigned-varint"
+10
Cargo.toml
··· 16 16 cid = "0.11.1" 17 17 dotenvy = "0.15.7" 18 18 futures = "0.3.30" 19 + hkdf = "0.12" 20 + hmac = "0.12" 21 + aes-gcm = "0.10" 19 22 jacquard = { version = "0.9.3", default-features = false, features = ["api", "api_bluesky", "api_full", "derive", "dns"] } 20 23 jacquard-axum = "0.9.2" 21 24 jacquard-repo = "0.9.2" ··· 30 33 serde_ipld_dagcbor = "0.6.4" 31 34 serde_json = "1.0.145" 32 35 sha2 = "0.10.9" 36 + subtle = "2.5" 37 + p256 = { version = "0.13", features = ["ecdsa"] } 38 + p384 = { version = "0.13", features = ["ecdsa"] } 39 + ed25519-dalek = { version = "2.1", features = ["pkcs8"] } 33 40 sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] } 34 41 thiserror = "2.0.17" 35 42 tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "time", "signal", "process"] } 36 43 tracing = "0.1.43" 37 44 tracing-subscriber = "0.3.22" 38 45 tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } 46 + urlencoding = "2.1" 39 47 uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } 40 48 41 49 [dev-dependencies] ··· 44 52 testcontainers = "0.26.0" 45 53 testcontainers-modules = { version = "0.14.0", features = ["postgres"] } 46 54 wiremock = "0.6.5" 55 + 56 + # urlencoding is also in dependencies, but tests use it directly
+47 -17
TODO.md
··· 110 110 ## Temp Namespace (`com.atproto.temp`) 111 111 - [ ] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). 112 112 113 - ## OAuth 2.0 Support 114 - The reference PDS implements full OAuth 2.0 provider functionality for native app authentication. 115 - - [ ] OAuth Provider Core 116 - - [ ] Implement `/.well-known/oauth-protected-resource` metadata endpoint. 117 - - [ ] Implement `/.well-known/oauth-authorization-server` metadata endpoint. 118 - - [ ] Implement `/oauth/authorize` authorization endpoint. 119 - - [ ] Implement `/oauth/par` Pushed Authorization Request endpoint. 120 - - [ ] Implement `/oauth/token` token endpoint. 121 - - [ ] Implement `/oauth/jwks` JSON Web Key Set endpoint. 122 - - [ ] OAuth Database Tables 123 - - [ ] Device table for tracking authorized devices. 124 - - [ ] Authorization request table. 125 - - [ ] Authorized client table. 126 - - [ ] Token table for OAuth tokens. 127 - - [ ] Used refresh token table. 128 - - [ ] DPoP (Demonstrating Proof-of-Possession) support. 129 - - [ ] Client metadata fetching and validation. 113 + ## OAuth 2.1 Support 114 + Full OAuth 2.1 provider for ATProto native app authentication. 115 + - [x] OAuth Provider Core 116 + - [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint. 117 + - [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint. 118 + - [x] Implement `/oauth/authorize` authorization endpoint (headless JSON mode). 119 + - [x] Implement `/oauth/par` Pushed Authorization Request endpoint. 120 + - [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants). 121 + - [x] Implement `/oauth/jwks` JSON Web Key Set endpoint. 122 + - [x] Implement `/oauth/revoke` token revocation endpoint. 123 + - [x] Implement `/oauth/introspect` token introspection endpoint. 124 + - [x] OAuth Database Tables 125 + - [x] Device table for tracking authorized devices. 126 + - [x] Authorization request table. 127 + - [x] Authorized client table. 128 + - [x] Token table for OAuth tokens. 129 + - [x] Used refresh token table (replay protection). 130 + - [x] DPoP JTI tracking table. 131 + - [x] DPoP (Demonstrating Proof-of-Possession) support. 132 + - [x] Client metadata fetching and validation. 133 + - [x] PKCE (S256) enforcement. 134 + - [x] OAuth token verification extractor for protected resources. 135 + - [ ] Authorization UI templates (currently headless-only, returns JSON for programmatic flows). 136 + - [ ] Implement `private_key_jwt` signature verification (currently rejects with clear error). 137 + 138 + ## OAuth Security Notes 139 + 140 + I've tried to ensure that this codebase is not vulnerable to the following: 141 + 142 + - Constant-time comparison for signature verification (prevents timing attacks) 143 + - HMAC-SHA256 for access token signing with configurable secret 144 + - Production secrets require 32+ character minimum 145 + - DPoP JTI replay protection via database 146 + - DPoP nonce validation with HMAC-based timestamps (5 min validity) 147 + - Refresh token rotation with reuse detection (revokes token family on reuse) 148 + - PKCE S256 enforced (plain not allowed) 149 + - Authorization code single-use enforcement 150 + - URL encoding for redirect parameters (prevents injection) 151 + - All database queries use parameterized statements (no SQL injection) 152 + - Deactivated/taken-down accounts blocked from OAuth authorization 153 + - Client ID validation on token exchange (defense-in-depth against cross-client attacks) 154 + 155 + ### Auth Notes 156 + - 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. 157 + - [ ] Support the ref PDS HS256 system too. 158 + - 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. 159 + - 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. 130 160 131 161 ## PDS-Level App Endpoints 132 162 These endpoints need to be implemented at the PDS level (not just proxied to appview).
+16 -21
justfile
··· 27 27 28 28 lint: fmt-check clippy 29 29 30 - test: 31 - cargo test 30 + # Run tests (auto-starts and auto-cleans containers) 31 + test *args: 32 + ./scripts/run-tests.sh {{args}} 32 33 33 - test-verbose: 34 - cargo test -- --nocapture 34 + # Run a specific test file 35 + test-file file: 36 + ./scripts/run-tests.sh --test {{file}} 35 37 36 - test-repo: 37 - cargo test --test repo 38 + # Run tests with testcontainers (slower, no shared infra) 39 + test-standalone: 40 + BSPDS_ALLOW_INSECURE_SECRETS=1 cargo test 38 41 39 - test-lifecycle: 40 - cargo test --test lifecycle 42 + # Manually manage test infrastructure (for debugging) 43 + test-infra-start: 44 + ./scripts/test-infra.sh start 41 45 42 - test-proxy: 43 - cargo test --test proxy 44 - 45 - test-sync: 46 - cargo test --test sync 47 - 48 - test-server: 49 - cargo test --test server 46 + test-infra-stop: 47 + ./scripts/test-infra.sh stop 50 48 51 - test-identity: 52 - cargo test --test identity 53 - 54 - test-auth: 55 - cargo test --test auth 49 + test-infra-status: 50 + ./scripts/test-infra.sh status 56 51 57 52 clean: 58 53 cargo clean
+125 -15
migrations/202512211400_initial_schema.sql
··· 18 18 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 19 19 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 20 20 21 - -- status & moderation 22 21 deactivated_at TIMESTAMPTZ, 23 22 invites_disabled BOOLEAN DEFAULT FALSE, 24 23 takedown_ref TEXT, 25 24 26 - -- notifs 27 25 preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', 28 26 29 - -- auth & verification 30 27 password_reset_code TEXT, 31 28 password_reset_code_expires_at TIMESTAMPTZ, 32 29 ··· 54 51 UNIQUE(code, used_by_user) 55 52 ); 56 53 57 - -- TODO: encrypt at rest! 58 54 CREATE TABLE IF NOT EXISTS user_keys ( 59 55 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 60 56 key_bytes BYTEA NOT NULL, 61 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 57 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 58 + encrypted_at TIMESTAMPTZ, 59 + encryption_version INTEGER DEFAULT 0 62 60 ); 63 61 64 62 CREATE TABLE IF NOT EXISTS repos ( ··· 68 66 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 69 67 ); 70 68 71 - -- content addressable storage 72 69 CREATE TABLE IF NOT EXISTS blocks ( 73 70 cid BYTEA PRIMARY KEY, 74 71 data BYTEA NOT NULL, 75 72 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 76 73 ); 77 74 78 - -- denormalized index for fast queries 79 75 CREATE TABLE IF NOT EXISTS records ( 80 76 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 81 77 repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE, ··· 97 93 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 98 94 ); 99 95 100 - CREATE TABLE IF NOT EXISTS sessions ( 101 - access_jwt TEXT PRIMARY KEY, 102 - refresh_jwt TEXT NOT NULL UNIQUE, 103 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 104 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 105 - ); 106 - 107 96 CREATE TABLE IF NOT EXISTS app_passwords ( 108 97 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 109 98 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, ··· 114 103 UNIQUE(user_id, name) 115 104 ); 116 105 117 - -- naughty list 118 106 CREATE TABLE reports ( 119 107 id BIGINT PRIMARY KEY, 120 108 reason_type TEXT NOT NULL, ··· 155 143 WHERE status = 'pending'; 156 144 157 145 CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); 146 + 147 + CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 148 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 149 + did TEXT, 150 + public_key_did_key TEXT NOT NULL, 151 + private_key_bytes BYTEA NOT NULL, 152 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 153 + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours', 154 + used_at TIMESTAMPTZ 155 + ); 156 + 157 + CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL; 158 + CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL; 159 + 160 + CREATE TABLE repo_seq ( 161 + seq BIGSERIAL PRIMARY KEY, 162 + did TEXT NOT NULL, 163 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 164 + event_type TEXT NOT NULL, 165 + commit_cid TEXT, 166 + prev_cid TEXT, 167 + ops JSONB, 168 + blobs TEXT[], 169 + blocks_cids TEXT[] 170 + ); 171 + 172 + CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 173 + CREATE INDEX idx_repo_seq_did ON repo_seq(did); 174 + 175 + CREATE TABLE IF NOT EXISTS session_tokens ( 176 + id SERIAL PRIMARY KEY, 177 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 178 + access_jti TEXT NOT NULL UNIQUE, 179 + refresh_jti TEXT NOT NULL UNIQUE, 180 + access_expires_at TIMESTAMPTZ NOT NULL, 181 + refresh_expires_at TIMESTAMPTZ NOT NULL, 182 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 183 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 184 + ); 185 + 186 + CREATE INDEX idx_session_tokens_did ON session_tokens(did); 187 + CREATE INDEX idx_session_tokens_access_jti ON session_tokens(access_jti); 188 + CREATE INDEX idx_session_tokens_refresh_jti ON session_tokens(refresh_jti); 189 + 190 + CREATE TABLE IF NOT EXISTS used_refresh_tokens ( 191 + refresh_jti TEXT PRIMARY KEY, 192 + session_id INTEGER NOT NULL REFERENCES session_tokens(id) ON DELETE CASCADE, 193 + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 194 + ); 195 + 196 + CREATE INDEX idx_used_refresh_tokens_session_id ON used_refresh_tokens(session_id); 197 + 198 + CREATE TABLE IF NOT EXISTS oauth_device ( 199 + id TEXT PRIMARY KEY, 200 + session_id TEXT NOT NULL UNIQUE, 201 + user_agent TEXT, 202 + ip_address TEXT NOT NULL, 203 + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 204 + ); 205 + 206 + CREATE TABLE IF NOT EXISTS oauth_authorization_request ( 207 + id TEXT PRIMARY KEY, 208 + did TEXT REFERENCES users(did) ON DELETE CASCADE, 209 + device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL, 210 + client_id TEXT NOT NULL, 211 + client_auth JSONB, 212 + parameters JSONB NOT NULL, 213 + expires_at TIMESTAMPTZ NOT NULL, 214 + code TEXT UNIQUE 215 + ); 216 + 217 + CREATE INDEX idx_oauth_auth_request_expires ON oauth_authorization_request(expires_at); 218 + CREATE INDEX idx_oauth_auth_request_code ON oauth_authorization_request(code) WHERE code IS NOT NULL; 219 + 220 + CREATE TABLE IF NOT EXISTS oauth_token ( 221 + id SERIAL PRIMARY KEY, 222 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 223 + token_id TEXT NOT NULL UNIQUE, 224 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 225 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 226 + expires_at TIMESTAMPTZ NOT NULL, 227 + client_id TEXT NOT NULL, 228 + client_auth JSONB NOT NULL, 229 + device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL, 230 + parameters JSONB NOT NULL, 231 + details JSONB, 232 + code TEXT UNIQUE, 233 + current_refresh_token TEXT UNIQUE, 234 + scope TEXT 235 + ); 236 + 237 + CREATE INDEX idx_oauth_token_did ON oauth_token(did); 238 + CREATE INDEX idx_oauth_token_code ON oauth_token(code) WHERE code IS NOT NULL; 239 + 240 + CREATE TABLE IF NOT EXISTS oauth_account_device ( 241 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 242 + device_id TEXT NOT NULL REFERENCES oauth_device(id) ON DELETE CASCADE, 243 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 244 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 245 + PRIMARY KEY (did, device_id) 246 + ); 247 + 248 + CREATE TABLE IF NOT EXISTS oauth_authorized_client ( 249 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 250 + client_id TEXT NOT NULL, 251 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 252 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 253 + data JSONB NOT NULL, 254 + PRIMARY KEY (did, client_id) 255 + ); 256 + 257 + CREATE TABLE IF NOT EXISTS oauth_used_refresh_token ( 258 + refresh_token TEXT PRIMARY KEY, 259 + token_id INTEGER NOT NULL REFERENCES oauth_token(id) ON DELETE CASCADE 260 + ); 261 + 262 + CREATE TABLE oauth_dpop_jti ( 263 + jti TEXT PRIMARY KEY, 264 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 265 + ); 266 + 267 + CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
-12
migrations/202512211401_reserved_signing_keys.sql
··· 1 - CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 2 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 - did TEXT, 4 - public_key_did_key TEXT NOT NULL, 5 - private_key_bytes BYTEA NOT NULL, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 - expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours', 8 - used_at TIMESTAMPTZ 9 - ); 10 - 11 - CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL; 12 - CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL;
-13
migrations/202512211402_repo_sequencer.sql
··· 1 - CREATE TABLE repo_seq ( 2 - seq BIGSERIAL PRIMARY KEY, 3 - did TEXT NOT NULL, 4 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 5 - event_type TEXT NOT NULL, 6 - commit_cid TEXT, 7 - prev_cid TEXT, 8 - ops JSONB, 9 - blobs TEXT[] 10 - ); 11 - 12 - CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 13 - CREATE INDEX idx_repo_seq_did ON repo_seq(did);
-2
migrations/202512211403_add_blocks_cids_to_repo_seq.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN blocks_cids TEXT[]; 2 -
+29
scripts/run-tests.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 + PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" 6 + INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh" 7 + 8 + cleanup() { 9 + echo "" 10 + echo "Cleaning up test infrastructure..." 11 + "$INFRA_SCRIPT" stop 12 + } 13 + 14 + trap cleanup EXIT 15 + 16 + "$INFRA_SCRIPT" start 17 + 18 + source "${TMPDIR:-/tmp}/bspds_test_infra.env" 19 + 20 + echo "" 21 + echo "Running database migrations..." 22 + sqlx database create 2>/dev/null || true 23 + sqlx migrate run --source "$PROJECT_DIR/migrations" 24 + 25 + echo "" 26 + echo "Running tests..." 27 + echo "" 28 + 29 + cargo nextest run "$@"
+166
scripts/test-infra.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + INFRA_FILE="${TMPDIR:-/tmp}/bspds_test_infra.env" 5 + CONTAINER_PREFIX="bspds-test" 6 + 7 + command_exists() { 8 + command -v "$1" >/dev/null 2>&1 9 + } 10 + 11 + if command_exists podman; then 12 + CONTAINER_CMD="podman" 13 + if [[ -z "${DOCKER_HOST:-}" ]]; then 14 + RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" 15 + PODMAN_SOCK="$RUNTIME_DIR/podman/podman.sock" 16 + if [[ -S "$PODMAN_SOCK" ]]; then 17 + export DOCKER_HOST="unix://$PODMAN_SOCK" 18 + fi 19 + fi 20 + elif command_exists docker; then 21 + CONTAINER_CMD="docker" 22 + else 23 + echo "Error: Neither podman nor docker found" >&2 24 + exit 1 25 + fi 26 + 27 + start_infra() { 28 + echo "Starting test infrastructure..." 29 + 30 + if [[ -f "$INFRA_FILE" ]]; then 31 + source "$INFRA_FILE" 32 + if $CONTAINER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_PREFIX}-postgres$"; then 33 + echo "Infrastructure already running (found $INFRA_FILE)" 34 + cat "$INFRA_FILE" 35 + return 0 36 + fi 37 + echo "Stale infra file found, cleaning up..." 38 + rm -f "$INFRA_FILE" 39 + fi 40 + 41 + $CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true 42 + 43 + echo "Starting PostgreSQL..." 44 + $CONTAINER_CMD run -d \ 45 + --name "${CONTAINER_PREFIX}-postgres" \ 46 + -e POSTGRES_PASSWORD=postgres \ 47 + -e POSTGRES_USER=postgres \ 48 + -e POSTGRES_DB=postgres \ 49 + -P \ 50 + --label bspds_test=true \ 51 + postgres:18-alpine >/dev/null 52 + 53 + echo "Starting MinIO..." 54 + $CONTAINER_CMD run -d \ 55 + --name "${CONTAINER_PREFIX}-minio" \ 56 + -e MINIO_ROOT_USER=minioadmin \ 57 + -e MINIO_ROOT_PASSWORD=minioadmin \ 58 + -P \ 59 + --label bspds_test=true \ 60 + minio/minio:latest server /data >/dev/null 61 + 62 + echo "Waiting for services to be ready..." 63 + sleep 2 64 + 65 + PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2) 66 + MINIO_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-minio" 9000 | head -1 | cut -d: -f2) 67 + 68 + for i in {1..30}; do 69 + if $CONTAINER_CMD exec "${CONTAINER_PREFIX}-postgres" pg_isready -U postgres >/dev/null 2>&1; then 70 + break 71 + fi 72 + echo "Waiting for PostgreSQL... ($i/30)" 73 + sleep 1 74 + done 75 + 76 + for i in {1..30}; do 77 + if curl -s "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null 2>&1; then 78 + break 79 + fi 80 + echo "Waiting for MinIO... ($i/30)" 81 + sleep 1 82 + done 83 + 84 + echo "Creating MinIO bucket..." 85 + $CONTAINER_CMD run --rm --network host \ 86 + -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 87 + minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true 88 + 89 + cat > "$INFRA_FILE" << EOF 90 + export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 91 + export TEST_DB_PORT="${PG_PORT}" 92 + export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 93 + export S3_BUCKET="test-bucket" 94 + export AWS_ACCESS_KEY_ID="minioadmin" 95 + export AWS_SECRET_ACCESS_KEY="minioadmin" 96 + export AWS_REGION="us-east-1" 97 + export BSPDS_TEST_INFRA_READY="1" 98 + export BSPDS_ALLOW_INSECURE_SECRETS="1" 99 + EOF 100 + 101 + echo "" 102 + echo "Infrastructure ready!" 103 + echo "Config written to: $INFRA_FILE" 104 + echo "" 105 + cat "$INFRA_FILE" 106 + } 107 + 108 + stop_infra() { 109 + echo "Stopping test infrastructure..." 110 + $CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true 111 + rm -f "$INFRA_FILE" 112 + echo "Infrastructure stopped." 113 + } 114 + 115 + status_infra() { 116 + echo "Test Infrastructure Status:" 117 + echo "============================" 118 + 119 + if [[ -f "$INFRA_FILE" ]]; then 120 + echo "Config file: $INFRA_FILE" 121 + source "$INFRA_FILE" 122 + echo "Database URL: $DATABASE_URL" 123 + echo "S3 Endpoint: $S3_ENDPOINT" 124 + else 125 + echo "Config file: NOT FOUND" 126 + fi 127 + 128 + echo "" 129 + echo "Containers:" 130 + $CONTAINER_CMD ps -a --filter "label=bspds_test=true" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (none)" 131 + } 132 + 133 + case "${1:-}" in 134 + start) 135 + start_infra 136 + ;; 137 + stop) 138 + stop_infra 139 + ;; 140 + restart) 141 + stop_infra 142 + start_infra 143 + ;; 144 + status) 145 + status_infra 146 + ;; 147 + env) 148 + if [[ -f "$INFRA_FILE" ]]; then 149 + cat "$INFRA_FILE" 150 + else 151 + echo "Infrastructure not running. Run: $0 start" >&2 152 + exit 1 153 + fi 154 + ;; 155 + *) 156 + echo "Usage: $0 {start|stop|restart|status|env}" 157 + echo "" 158 + echo "Commands:" 159 + echo " start - Start test infrastructure (Postgres, MinIO)" 160 + echo " stop - Stop and remove test containers" 161 + echo " restart - Stop then start infrastructure" 162 + echo " status - Show infrastructure status" 163 + echo " env - Output environment variables for sourcing" 164 + exit 1 165 + ;; 166 + esac
+1 -1
src/api/admin/account.rs
··· 214 214 } 215 215 }; 216 216 217 - let _ = sqlx::query!("DELETE FROM sessions WHERE did = $1", did) 217 + let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) 218 218 .execute(&state.db) 219 219 .await; 220 220
+16 -33
src/api/feed/timeline.rs
··· 44 44 State(state): State<AppState>, 45 45 headers: axum::http::HeaderMap, 46 46 ) -> Response { 47 - let auth_header = headers.get("Authorization"); 48 - if auth_header.is_none() { 49 - return ( 50 - StatusCode::UNAUTHORIZED, 51 - Json(json!({"error": "AuthenticationRequired"})), 52 - ) 53 - .into_response(); 54 - } 55 - let token = auth_header 56 - .unwrap() 57 - .to_str() 58 - .unwrap_or("") 59 - .replace("Bearer ", ""); 47 + let token = match crate::auth::extract_bearer_token_from_header( 48 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 49 + ) { 50 + Some(t) => t, 51 + None => { 52 + return ( 53 + StatusCode::UNAUTHORIZED, 54 + Json(json!({"error": "AuthenticationRequired"})), 55 + ) 56 + .into_response(); 57 + } 58 + }; 60 59 61 - let session = sqlx::query!( 62 - "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", 63 - token 64 - ) 65 - .fetch_optional(&state.db) 66 - .await 67 - .unwrap_or(None); 68 - 69 - let (did, key_bytes) = match session { 70 - Some(row) => (row.did, row.key_bytes), 71 - None => { 60 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 61 + Ok(user) => user, 62 + Err(_) => { 72 63 return ( 73 64 StatusCode::UNAUTHORIZED, 74 65 Json(json!({"error": "AuthenticationFailed"})), ··· 77 68 } 78 69 }; 79 70 80 - if crate::auth::verify_token(&token, &key_bytes).is_err() { 81 - return ( 82 - StatusCode::UNAUTHORIZED, 83 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 84 - ) 85 - .into_response(); 86 - } 87 - 88 - let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 71 + let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did) 89 72 .fetch_optional(&state.db) 90 73 .await; 91 74
+31 -11
src/api/identity/account.rs
··· 228 228 (secret_key.to_bytes().to_vec(), None) 229 229 }; 230 230 231 + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 232 + Ok(enc) => enc, 233 + Err(e) => { 234 + error!("Error encrypting user key: {:?}", e); 235 + return ( 236 + StatusCode::INTERNAL_SERVER_ERROR, 237 + Json(json!({"error": "InternalError"})), 238 + ) 239 + .into_response(); 240 + } 241 + }; 242 + 231 243 let key_insert = sqlx::query!( 232 - "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", 244 + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 233 245 user_id, 234 - &secret_key_bytes[..] 246 + &encrypted_key_bytes[..], 247 + crate::config::ENCRYPTION_VERSION 235 248 ) 236 249 .execute(&mut *tx) 237 250 .await; ··· 345 358 } 346 359 } 347 360 348 - let access_jwt = crate::auth::create_access_token(&did, &secret_key_bytes[..]).map_err(|e| { 361 + let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { 349 362 error!("Error creating access token: {:?}", e); 350 363 ( 351 364 StatusCode::INTERNAL_SERVER_ERROR, ··· 353 366 ) 354 367 .into_response() 355 368 }); 356 - let access_jwt = match access_jwt { 357 - Ok(t) => t, 369 + let access_meta = match access_meta { 370 + Ok(m) => m, 358 371 Err(r) => return r, 359 372 }; 360 373 361 - let refresh_jwt = crate::auth::create_refresh_token(&did, &secret_key_bytes[..]).map_err(|e| { 374 + let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { 362 375 error!("Error creating refresh token: {:?}", e); 363 376 ( 364 377 StatusCode::INTERNAL_SERVER_ERROR, ··· 366 379 ) 367 380 .into_response() 368 381 }); 369 - let refresh_jwt = match refresh_jwt { 370 - Ok(t) => t, 382 + let refresh_meta = match refresh_meta { 383 + Ok(m) => m, 371 384 Err(r) => return r, 372 385 }; 373 386 374 387 let session_insert = 375 - sqlx::query!("INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", access_jwt, refresh_jwt, did) 388 + sqlx::query!( 389 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 390 + did, 391 + access_meta.jti, 392 + refresh_meta.jti, 393 + access_meta.expires_at, 394 + refresh_meta.expires_at 395 + ) 376 396 .execute(&mut *tx) 377 397 .await; 378 398 ··· 410 430 ( 411 431 StatusCode::OK, 412 432 Json(CreateAccountOutput { 413 - access_jwt, 414 - refresh_jwt, 433 + access_jwt: access_meta.token, 434 + refresh_jwt: refresh_meta.token, 415 435 handle: input.handle, 416 436 did, 417 437 }),
+74 -83
src/api/identity/did.rs
··· 121 121 .into_response(); 122 122 } 123 123 124 - let key_row = sqlx::query!("SELECT key_bytes FROM user_keys WHERE user_id = $1", user_id) 124 + let key_row = sqlx::query!("SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id) 125 125 .fetch_optional(&state.db) 126 126 .await; 127 127 128 128 let key_bytes: Vec<u8> = match key_row { 129 - Ok(Some(row)) => row.key_bytes, 129 + Ok(Some(row)) => { 130 + match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 131 + Ok(k) => k, 132 + Err(_) => { 133 + return ( 134 + StatusCode::INTERNAL_SERVER_ERROR, 135 + Json(json!({"error": "InternalError"})), 136 + ) 137 + .into_response(); 138 + } 139 + } 140 + } 130 141 _ => { 131 142 return ( 132 143 StatusCode::INTERNAL_SERVER_ERROR, ··· 270 281 State(state): State<AppState>, 271 282 headers: axum::http::HeaderMap, 272 283 ) -> Response { 273 - let auth_header = headers.get("Authorization"); 274 - if auth_header.is_none() { 275 - return ( 276 - StatusCode::UNAUTHORIZED, 277 - Json(json!({"error": "AuthenticationRequired"})), 278 - ) 279 - .into_response(); 280 - } 281 - 282 - let token = auth_header 283 - .unwrap() 284 - .to_str() 285 - .unwrap_or("") 286 - .replace("Bearer ", ""); 287 - 288 - let session = sqlx::query!( 289 - r#" 290 - SELECT s.did, k.key_bytes, u.handle 291 - FROM sessions s 292 - JOIN users u ON s.did = u.did 293 - JOIN user_keys k ON u.id = k.user_id 294 - WHERE s.access_jwt = $1 295 - "#, 296 - token 297 - ) 298 - .fetch_optional(&state.db) 299 - .await; 300 - 301 - let (_did, key_bytes, handle) = match session { 302 - Ok(Some(row)) => (row.did, row.key_bytes, row.handle), 303 - Ok(None) => { 284 + let token = match crate::auth::extract_bearer_token_from_header( 285 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 286 + ) { 287 + Some(t) => t, 288 + None => { 304 289 return ( 305 290 StatusCode::UNAUTHORIZED, 306 - Json(json!({"error": "AuthenticationFailed"})), 291 + Json(json!({"error": "AuthenticationRequired"})), 307 292 ) 308 293 .into_response(); 309 294 } 295 + }; 296 + 297 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 298 + let did = match auth_result { 299 + Ok(ref user) => user.did.clone(), 310 300 Err(e) => { 311 - error!("DB error in get_recommended_did_credentials: {:?}", e); 301 + return ( 302 + StatusCode::UNAUTHORIZED, 303 + Json(json!({"error": e})), 304 + ) 305 + .into_response(); 306 + } 307 + }; 308 + 309 + 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) 310 + .fetch_optional(&state.db) 311 + .await 312 + { 313 + Ok(Some(row)) => row, 314 + _ => { 312 315 return ( 313 316 StatusCode::INTERNAL_SERVER_ERROR, 314 317 Json(json!({"error": "InternalError"})), ··· 316 319 .into_response(); 317 320 } 318 321 }; 322 + let handle = user.handle; 319 323 320 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 321 - return ( 322 - StatusCode::UNAUTHORIZED, 323 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 324 - ) 325 - .into_response(); 326 - } 324 + let key_bytes = match auth_result.ok().and_then(|u| u.key_bytes) { 325 + Some(kb) => kb, 326 + None => { 327 + return ( 328 + StatusCode::UNAUTHORIZED, 329 + Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot get DID credentials"})), 330 + ) 331 + .into_response(); 332 + } 333 + }; 327 334 328 335 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 329 336 let pds_endpoint = format!("https://{}", hostname); ··· 376 383 headers: axum::http::HeaderMap, 377 384 Json(input): Json<UpdateHandleInput>, 378 385 ) -> Response { 379 - let auth_header = headers.get("Authorization"); 380 - if auth_header.is_none() { 381 - return ( 382 - StatusCode::UNAUTHORIZED, 383 - Json(json!({"error": "AuthenticationRequired"})), 384 - ) 385 - .into_response(); 386 - } 387 - 388 - let token = auth_header 389 - .unwrap() 390 - .to_str() 391 - .unwrap_or("") 392 - .replace("Bearer ", ""); 393 - 394 - let session = sqlx::query!( 395 - r#" 396 - SELECT s.did, k.key_bytes, u.id as user_id 397 - FROM sessions s 398 - JOIN users u ON s.did = u.did 399 - JOIN user_keys k ON u.id = k.user_id 400 - WHERE s.access_jwt = $1 401 - "#, 402 - token 403 - ) 404 - .fetch_optional(&state.db) 405 - .await; 386 + let token = match crate::auth::extract_bearer_token_from_header( 387 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 388 + ) { 389 + Some(t) => t, 390 + None => { 391 + return ( 392 + StatusCode::UNAUTHORIZED, 393 + Json(json!({"error": "AuthenticationRequired"})), 394 + ) 395 + .into_response(); 396 + } 397 + }; 406 398 407 - let (_did, key_bytes, user_id) = match session { 408 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 409 - Ok(None) => { 399 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 400 + let did = match auth_result { 401 + Ok(user) => user.did, 402 + Err(e) => { 410 403 return ( 411 404 StatusCode::UNAUTHORIZED, 412 - Json(json!({"error": "AuthenticationFailed"})), 405 + Json(json!({"error": e})), 413 406 ) 414 407 .into_response(); 415 408 } 416 - Err(e) => { 417 - error!("DB error in update_handle: {:?}", e); 409 + }; 410 + 411 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 412 + .fetch_optional(&state.db) 413 + .await 414 + { 415 + Ok(Some(id)) => id, 416 + _ => { 418 417 return ( 419 418 StatusCode::INTERNAL_SERVER_ERROR, 420 419 Json(json!({"error": "InternalError"})), ··· 422 421 .into_response(); 423 422 } 424 423 }; 425 - 426 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 427 - return ( 428 - StatusCode::UNAUTHORIZED, 429 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 430 - ) 431 - .into_response(); 432 - } 433 424 434 425 let new_handle = input.handle.trim(); 435 426 if new_handle.is_empty() {
+13 -43
src/api/moderation/mod.rs
··· 33 33 headers: axum::http::HeaderMap, 34 34 Json(input): Json<CreateReportInput>, 35 35 ) -> Response { 36 - let auth_header = headers.get("Authorization"); 37 - if auth_header.is_none() { 38 - return ( 39 - StatusCode::UNAUTHORIZED, 40 - Json(json!({"error": "AuthenticationRequired"})), 41 - ) 42 - .into_response(); 43 - } 44 - 45 - let token = auth_header 46 - .unwrap() 47 - .to_str() 48 - .unwrap_or("") 49 - .replace("Bearer ", ""); 50 - 51 - let session = sqlx::query!( 52 - r#" 53 - SELECT s.did, k.key_bytes 54 - FROM sessions s 55 - JOIN users u ON s.did = u.did 56 - JOIN user_keys k ON u.id = k.user_id 57 - WHERE s.access_jwt = $1 58 - "#, 59 - token 60 - ) 61 - .fetch_optional(&state.db) 62 - .await; 63 - 64 - let (did, key_bytes) = match session { 65 - Ok(Some(row)) => (row.did, row.key_bytes), 66 - Ok(None) => { 36 + let token = match crate::auth::extract_bearer_token_from_header( 37 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 38 + ) { 39 + Some(t) => t, 40 + None => { 67 41 return ( 68 42 StatusCode::UNAUTHORIZED, 69 - Json(json!({"error": "AuthenticationFailed"})), 43 + Json(json!({"error": "AuthenticationRequired"})), 70 44 ) 71 45 .into_response(); 72 46 } 47 + }; 48 + 49 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 50 + let did = match auth_result { 51 + Ok(user) => user.did, 73 52 Err(e) => { 74 - error!("DB error in create_report: {:?}", e); 75 53 return ( 76 - StatusCode::INTERNAL_SERVER_ERROR, 77 - Json(json!({"error": "InternalError"})), 54 + StatusCode::UNAUTHORIZED, 55 + Json(json!({"error": e})), 78 56 ) 79 57 .into_response(); 80 58 } 81 59 }; 82 - 83 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 84 - return ( 85 - StatusCode::UNAUTHORIZED, 86 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 87 - ) 88 - .into_response(); 89 - } 90 60 91 61 let valid_reason_types = [ 92 62 "com.atproto.moderation.defs#reasonSpam",
+10 -9
src/api/proxy.rs
··· 43 43 let mut auth_header_val = headers.get("Authorization").map(|h| h.clone()); 44 44 45 45 if let Some(aud) = &proxy_header { 46 - if let Some(auth_val) = &auth_header_val { 47 - if let Ok(token) = auth_val.to_str() { 48 - let token = token.replace("Bearer ", ""); 49 - if let Ok(did) = crate::auth::get_did_from_token(&token) { 50 - 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) 51 - .fetch_optional(&state.db) 52 - .await; 46 + if let Some(token) = crate::auth::extract_bearer_token_from_header( 47 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 48 + ) { 49 + if let Ok(did) = crate::auth::get_did_from_token(&token) { 50 + 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) 51 + .fetch_optional(&state.db) 52 + .await; 53 53 54 - if let Ok(Some(row)) = key_row { 54 + if let Ok(Some(row)) = key_row { 55 + if let Ok(decrypted_key) = crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 55 56 if let Ok(new_token) = 56 - crate::auth::create_service_token(&did, aud, &method, &row.key_bytes) 57 + crate::auth::create_service_token(&did, aud, &method, &decrypted_key) 57 58 { 58 59 if let Ok(val) = 59 60 axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
+32 -64
src/api/repo/blob.rs
··· 20 20 headers: axum::http::HeaderMap, 21 21 body: Bytes, 22 22 ) -> Response { 23 - let auth_header = headers.get("Authorization"); 24 - if auth_header.is_none() { 25 - return ( 26 - StatusCode::UNAUTHORIZED, 27 - Json(json!({"error": "AuthenticationRequired"})), 28 - ) 29 - .into_response(); 30 - } 31 - let token = auth_header 32 - .unwrap() 33 - .to_str() 34 - .unwrap_or("") 35 - .replace("Bearer ", ""); 36 - 37 - let session = sqlx::query!( 38 - "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", 39 - token 40 - ) 41 - .fetch_optional(&state.db) 42 - .await 43 - .unwrap_or(None); 44 - 45 - let (did, key_bytes) = match session { 46 - Some(row) => (row.did, row.key_bytes), 23 + let token = match crate::auth::extract_bearer_token_from_header( 24 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 25 + ) { 26 + Some(t) => t, 47 27 None => { 48 28 return ( 49 29 StatusCode::UNAUTHORIZED, 50 - Json(json!({"error": "AuthenticationFailed"})), 30 + Json(json!({"error": "AuthenticationRequired"})), 51 31 ) 52 32 .into_response(); 53 33 } 54 34 }; 55 35 56 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 57 - return ( 58 - StatusCode::UNAUTHORIZED, 59 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 60 - ) 61 - .into_response(); 62 - } 36 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 37 + Ok(user) => user, 38 + Err(_) => { 39 + return ( 40 + StatusCode::UNAUTHORIZED, 41 + Json(json!({"error": "AuthenticationFailed"})), 42 + ) 43 + .into_response(); 44 + } 45 + }; 46 + let did = auth_user.did; 63 47 64 48 let mime_type = headers 65 49 .get("content-type") ··· 182 166 headers: axum::http::HeaderMap, 183 167 Query(params): Query<ListMissingBlobsParams>, 184 168 ) -> Response { 185 - let auth_header = headers.get("Authorization"); 186 - if auth_header.is_none() { 187 - return ( 188 - StatusCode::UNAUTHORIZED, 189 - Json(json!({"error": "AuthenticationRequired"})), 190 - ) 191 - .into_response(); 192 - } 193 - 194 - let token = auth_header 195 - .unwrap() 196 - .to_str() 197 - .unwrap_or("") 198 - .replace("Bearer ", ""); 199 - 200 - let session = sqlx::query!( 201 - "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", 202 - token 203 - ) 204 - .fetch_optional(&state.db) 205 - .await 206 - .unwrap_or(None); 207 - 208 - let (did, key_bytes) = match session { 209 - Some(row) => (row.did, row.key_bytes), 169 + let token = match crate::auth::extract_bearer_token_from_header( 170 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 171 + ) { 172 + Some(t) => t, 210 173 None => { 211 174 return ( 212 175 StatusCode::UNAUTHORIZED, 176 + Json(json!({"error": "AuthenticationRequired"})), 177 + ) 178 + .into_response(); 179 + } 180 + }; 181 + 182 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 183 + Ok(user) => user, 184 + Err(_) => { 185 + return ( 186 + StatusCode::UNAUTHORIZED, 213 187 Json(json!({"error": "AuthenticationFailed"})), 214 188 ) 215 189 .into_response(); 216 190 } 217 191 }; 218 192 219 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 220 - return ( 221 - StatusCode::UNAUTHORIZED, 222 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 223 - ) 224 - .into_response(); 225 - } 193 + let did = auth_user.did; 226 194 227 195 let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 228 196 .fetch_optional(&state.db)
+16 -31
src/api/repo/record/batch.rs
··· 73 73 headers: axum::http::HeaderMap, 74 74 Json(input): Json<ApplyWritesInput>, 75 75 ) -> Response { 76 - let auth_header = headers.get("Authorization"); 77 - if auth_header.is_none() { 78 - return ( 79 - StatusCode::UNAUTHORIZED, 80 - Json(json!({"error": "AuthenticationRequired"})), 81 - ) 82 - .into_response(); 83 - } 84 - let token = auth_header 85 - .unwrap() 86 - .to_str() 87 - .unwrap_or("") 88 - .replace("Bearer ", ""); 76 + let token = match crate::auth::extract_bearer_token_from_header( 77 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 78 + ) { 79 + Some(t) => t, 80 + None => { 81 + return ( 82 + StatusCode::UNAUTHORIZED, 83 + Json(json!({"error": "AuthenticationRequired"})), 84 + ) 85 + .into_response(); 86 + } 87 + }; 89 88 90 - let session = sqlx::query!( 91 - "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", 92 - token 93 - ) 94 - .fetch_optional(&state.db) 95 - .await 96 - .unwrap_or(None); 97 - 98 - let (did, key_bytes) = match session { 99 - Some(row) => (row.did, row.key_bytes), 100 - None => { 89 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 90 + Ok(user) => user, 91 + Err(_) => { 101 92 return ( 102 93 StatusCode::UNAUTHORIZED, 103 94 Json(json!({"error": "AuthenticationFailed"})), ··· 106 97 } 107 98 }; 108 99 109 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 110 - return ( 111 - StatusCode::UNAUTHORIZED, 112 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 113 - ) 114 - .into_response(); 115 - } 100 + let did = auth_user.did; 116 101 117 102 if input.repo != did { 118 103 return (
+15 -33
src/api/repo/record/write.rs
··· 23 23 headers: &HeaderMap, 24 24 repo_did: &str, 25 25 ) -> Result<(String, Uuid, Cid), Response> { 26 - let auth_header = headers.get("Authorization").ok_or_else(|| { 26 + let token = crate::auth::extract_bearer_token_from_header( 27 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 28 + ).ok_or_else(|| { 27 29 ( 28 30 StatusCode::UNAUTHORIZED, 29 31 Json(json!({"error": "AuthenticationRequired"})), 30 32 ) 31 33 .into_response() 32 34 })?; 33 - let token = auth_header 34 - .to_str() 35 - .unwrap_or("") 36 - .replace("Bearer ", ""); 37 35 38 - let session = sqlx::query!( 39 - "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", 40 - token 41 - ) 42 - .fetch_optional(&state.db) 43 - .await 44 - .map_err(|e| { 45 - error!("DB error fetching session: {}", e); 46 - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response() 47 - })? 48 - .ok_or_else(|| { 49 - ( 50 - StatusCode::UNAUTHORIZED, 51 - Json(json!({"error": "AuthenticationFailed"})), 52 - ) 53 - .into_response() 54 - })?; 55 - 56 - crate::auth::verify_token(&token, &session.key_bytes).map_err(|_| { 57 - ( 58 - StatusCode::UNAUTHORIZED, 59 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 60 - ) 61 - .into_response() 62 - })?; 36 + let auth_user = crate::auth::validate_bearer_token(&state.db, &token) 37 + .await 38 + .map_err(|_| { 39 + ( 40 + StatusCode::UNAUTHORIZED, 41 + Json(json!({"error": "AuthenticationFailed"})), 42 + ) 43 + .into_response() 44 + })?; 63 45 64 - if repo_did != session.did { 46 + if repo_did != auth_user.did { 65 47 return Err(( 66 48 StatusCode::FORBIDDEN, 67 49 Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), ··· 69 51 .into_response()); 70 52 } 71 53 72 - let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", session.did) 54 + let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 73 55 .fetch_optional(&state.db) 74 56 .await 75 57 .map_err(|e| { ··· 108 90 .into_response() 109 91 })?; 110 92 111 - Ok((session.did, user_id, current_root_cid)) 93 + Ok((auth_user.did, user_id, current_root_cid)) 112 94 } 113 95 114 96 #[derive(Deserialize)]
+80 -169
src/api/server/account_status.rs
··· 30 30 State(state): State<AppState>, 31 31 headers: axum::http::HeaderMap, 32 32 ) -> Response { 33 - let auth_header = headers.get("Authorization"); 34 - if auth_header.is_none() { 35 - return ( 36 - StatusCode::UNAUTHORIZED, 37 - Json(json!({"error": "AuthenticationRequired"})), 38 - ) 39 - .into_response(); 40 - } 41 - 42 - let token = auth_header 43 - .unwrap() 44 - .to_str() 45 - .unwrap_or("") 46 - .replace("Bearer ", ""); 33 + let token = match crate::auth::extract_bearer_token_from_header( 34 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 35 + ) { 36 + Some(t) => t, 37 + None => { 38 + return ( 39 + StatusCode::UNAUTHORIZED, 40 + Json(json!({"error": "AuthenticationRequired"})), 41 + ) 42 + .into_response(); 43 + } 44 + }; 47 45 48 - let session = sqlx::query!( 49 - r#" 50 - SELECT s.did, k.key_bytes, u.id as user_id 51 - FROM sessions s 52 - JOIN users u ON s.did = u.did 53 - JOIN user_keys k ON u.id = k.user_id 54 - WHERE s.access_jwt = $1 55 - "#, 56 - token 57 - ) 58 - .fetch_optional(&state.db) 59 - .await; 60 - 61 - let (did, key_bytes, user_id) = match session { 62 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 63 - Ok(None) => { 46 + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; 47 + let did = match auth_result { 48 + Ok(user) => user.did, 49 + Err(e) => { 64 50 return ( 65 51 StatusCode::UNAUTHORIZED, 66 - Json(json!({"error": "AuthenticationFailed"})), 52 + Json(json!({"error": e})), 67 53 ) 68 54 .into_response(); 69 55 } 70 - Err(e) => { 71 - error!("DB error in check_account_status: {:?}", e); 56 + }; 57 + 58 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 59 + .fetch_optional(&state.db) 60 + .await 61 + { 62 + Ok(Some(id)) => id, 63 + _ => { 72 64 return ( 73 65 StatusCode::INTERNAL_SERVER_ERROR, 74 66 Json(json!({"error": "InternalError"})), ··· 77 69 } 78 70 }; 79 71 80 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 81 - return ( 82 - StatusCode::UNAUTHORIZED, 83 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 84 - ) 85 - .into_response(); 86 - } 87 - 88 72 let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did) 89 73 .fetch_optional(&state.db) 90 74 .await; ··· 139 123 State(state): State<AppState>, 140 124 headers: axum::http::HeaderMap, 141 125 ) -> Response { 142 - let auth_header = headers.get("Authorization"); 143 - if auth_header.is_none() { 144 - return ( 145 - StatusCode::UNAUTHORIZED, 146 - Json(json!({"error": "AuthenticationRequired"})), 147 - ) 148 - .into_response(); 149 - } 150 - 151 - let token = auth_header 152 - .unwrap() 153 - .to_str() 154 - .unwrap_or("") 155 - .replace("Bearer ", ""); 156 - 157 - let session = sqlx::query!( 158 - r#" 159 - SELECT s.did, k.key_bytes 160 - FROM sessions s 161 - JOIN users u ON s.did = u.did 162 - JOIN user_keys k ON u.id = k.user_id 163 - WHERE s.access_jwt = $1 164 - "#, 165 - token 166 - ) 167 - .fetch_optional(&state.db) 168 - .await; 169 - 170 - let (did, key_bytes) = match session { 171 - Ok(Some(row)) => (row.did, row.key_bytes), 172 - Ok(None) => { 126 + let token = match crate::auth::extract_bearer_token_from_header( 127 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 128 + ) { 129 + Some(t) => t, 130 + None => { 173 131 return ( 174 132 StatusCode::UNAUTHORIZED, 175 - Json(json!({"error": "AuthenticationFailed"})), 133 + Json(json!({"error": "AuthenticationRequired"})), 176 134 ) 177 135 .into_response(); 178 136 } 137 + }; 138 + 139 + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; 140 + let did = match auth_result { 141 + Ok(user) => user.did, 179 142 Err(e) => { 180 - error!("DB error in activate_account: {:?}", e); 181 143 return ( 182 - StatusCode::INTERNAL_SERVER_ERROR, 183 - Json(json!({"error": "InternalError"})), 144 + StatusCode::UNAUTHORIZED, 145 + Json(json!({"error": e})), 184 146 ) 185 147 .into_response(); 186 148 } 187 149 }; 188 - 189 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 190 - return ( 191 - StatusCode::UNAUTHORIZED, 192 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 193 - ) 194 - .into_response(); 195 - } 196 150 197 151 let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) 198 152 .execute(&state.db) ··· 222 176 headers: axum::http::HeaderMap, 223 177 Json(_input): Json<DeactivateAccountInput>, 224 178 ) -> Response { 225 - let auth_header = headers.get("Authorization"); 226 - if auth_header.is_none() { 227 - return ( 228 - StatusCode::UNAUTHORIZED, 229 - Json(json!({"error": "AuthenticationRequired"})), 230 - ) 231 - .into_response(); 232 - } 233 - 234 - let token = auth_header 235 - .unwrap() 236 - .to_str() 237 - .unwrap_or("") 238 - .replace("Bearer ", ""); 239 - 240 - let session = sqlx::query!( 241 - r#" 242 - SELECT s.did, k.key_bytes 243 - FROM sessions s 244 - JOIN users u ON s.did = u.did 245 - JOIN user_keys k ON u.id = k.user_id 246 - WHERE s.access_jwt = $1 247 - "#, 248 - token 249 - ) 250 - .fetch_optional(&state.db) 251 - .await; 252 - 253 - let (did, key_bytes) = match session { 254 - Ok(Some(row)) => (row.did, row.key_bytes), 255 - Ok(None) => { 179 + let token = match crate::auth::extract_bearer_token_from_header( 180 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 181 + ) { 182 + Some(t) => t, 183 + None => { 256 184 return ( 257 185 StatusCode::UNAUTHORIZED, 258 - Json(json!({"error": "AuthenticationFailed"})), 186 + Json(json!({"error": "AuthenticationRequired"})), 259 187 ) 260 188 .into_response(); 261 189 } 190 + }; 191 + 192 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 193 + let did = match auth_result { 194 + Ok(user) => user.did, 262 195 Err(e) => { 263 - error!("DB error in deactivate_account: {:?}", e); 264 196 return ( 265 - StatusCode::INTERNAL_SERVER_ERROR, 266 - Json(json!({"error": "InternalError"})), 197 + StatusCode::UNAUTHORIZED, 198 + Json(json!({"error": e})), 267 199 ) 268 200 .into_response(); 269 201 } 270 202 }; 271 203 272 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 273 - return ( 274 - StatusCode::UNAUTHORIZED, 275 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 276 - ) 277 - .into_response(); 278 - } 279 - 280 204 let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did) 281 205 .execute(&state.db) 282 206 .await; ··· 298 222 State(state): State<AppState>, 299 223 headers: axum::http::HeaderMap, 300 224 ) -> Response { 301 - let auth_header = headers.get("Authorization"); 302 - if auth_header.is_none() { 303 - return ( 304 - StatusCode::UNAUTHORIZED, 305 - Json(json!({"error": "AuthenticationRequired"})), 306 - ) 307 - .into_response(); 308 - } 225 + let token = match crate::auth::extract_bearer_token_from_header( 226 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 227 + ) { 228 + Some(t) => t, 229 + None => { 230 + return ( 231 + StatusCode::UNAUTHORIZED, 232 + Json(json!({"error": "AuthenticationRequired"})), 233 + ) 234 + .into_response(); 235 + } 236 + }; 309 237 310 - let token = auth_header 311 - .unwrap() 312 - .to_str() 313 - .unwrap_or("") 314 - .replace("Bearer ", ""); 315 - 316 - let session = sqlx::query!( 317 - r#" 318 - SELECT s.did, u.id as user_id, u.email, u.handle, k.key_bytes 319 - FROM sessions s 320 - JOIN users u ON s.did = u.did 321 - JOIN user_keys k ON u.id = k.user_id 322 - WHERE s.access_jwt = $1 323 - "#, 324 - token 325 - ) 326 - .fetch_optional(&state.db) 327 - .await; 328 - 329 - let (did, user_id, email, handle, key_bytes) = match session { 330 - Ok(Some(row)) => (row.did, row.user_id, row.email, row.handle, row.key_bytes), 331 - Ok(None) => { 238 + let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; 239 + let did = match auth_result { 240 + Ok(user) => user.did, 241 + Err(e) => { 332 242 return ( 333 243 StatusCode::UNAUTHORIZED, 334 - Json(json!({"error": "AuthenticationFailed"})), 244 + Json(json!({"error": e})), 335 245 ) 336 246 .into_response(); 337 247 } 338 - Err(e) => { 339 - error!("DB error in request_account_delete: {:?}", e); 248 + }; 249 + 250 + let user = match sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did) 251 + .fetch_optional(&state.db) 252 + .await 253 + { 254 + Ok(Some(row)) => row, 255 + _ => { 340 256 return ( 341 257 StatusCode::INTERNAL_SERVER_ERROR, 342 258 Json(json!({"error": "InternalError"})), ··· 344 260 .into_response(); 345 261 } 346 262 }; 347 - 348 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 349 - return ( 350 - StatusCode::UNAUTHORIZED, 351 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 352 - ) 353 - .into_response(); 354 - } 263 + let user_id = user.id; 264 + let email = user.email; 265 + let handle = user.handle; 355 266 356 267 let confirmation_token = Uuid::new_v4().to_string(); 357 268 let expires_at = Utc::now() + Duration::minutes(15); ··· 541 452 }; 542 453 543 454 let deletion_result: Result<(), sqlx::Error> = async { 544 - sqlx::query!("DELETE FROM sessions WHERE did = $1", did) 455 + sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) 545 456 .execute(&mut *tx) 546 457 .await?; 547 458
+75 -123
src/api/server/app_password.rs
··· 26 26 State(state): State<AppState>, 27 27 headers: axum::http::HeaderMap, 28 28 ) -> Response { 29 - let auth_header = headers.get("Authorization"); 30 - if auth_header.is_none() { 31 - return ( 32 - StatusCode::UNAUTHORIZED, 33 - Json(json!({"error": "AuthenticationRequired"})), 34 - ) 35 - .into_response(); 36 - } 37 - 38 - let token = auth_header 39 - .unwrap() 40 - .to_str() 41 - .unwrap_or("") 42 - .replace("Bearer ", ""); 43 - 44 - let session = sqlx::query!( 45 - r#" 46 - SELECT s.did, k.key_bytes, u.id as user_id 47 - FROM sessions s 48 - JOIN users u ON s.did = u.did 49 - JOIN user_keys k ON u.id = k.user_id 50 - WHERE s.access_jwt = $1 51 - "#, 52 - token 53 - ) 54 - .fetch_optional(&state.db) 55 - .await; 29 + let token = match crate::auth::extract_bearer_token_from_header( 30 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 31 + ) { 32 + Some(t) => t, 33 + None => { 34 + return ( 35 + StatusCode::UNAUTHORIZED, 36 + Json(json!({"error": "AuthenticationRequired"})), 37 + ) 38 + .into_response(); 39 + } 40 + }; 56 41 57 - let (_did, key_bytes, user_id) = match session { 58 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 59 - Ok(None) => { 42 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 43 + let did = match auth_result { 44 + Ok(user) => user.did, 45 + Err(e) => { 60 46 return ( 61 47 StatusCode::UNAUTHORIZED, 62 - Json(json!({"error": "AuthenticationFailed"})), 48 + Json(json!({"error": e})), 63 49 ) 64 50 .into_response(); 65 51 } 66 - Err(e) => { 67 - error!("DB error in list_app_passwords: {:?}", e); 52 + }; 53 + 54 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 55 + .fetch_optional(&state.db) 56 + .await 57 + { 58 + Ok(Some(id)) => id, 59 + _ => { 68 60 return ( 69 61 StatusCode::INTERNAL_SERVER_ERROR, 70 62 Json(json!({"error": "InternalError"})), ··· 72 64 .into_response(); 73 65 } 74 66 }; 75 - 76 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 77 - return ( 78 - StatusCode::UNAUTHORIZED, 79 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 80 - ) 81 - .into_response(); 82 - } 83 67 84 68 let result = sqlx::query!("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", user_id) 85 69 .fetch_all(&state.db) ··· 131 115 headers: axum::http::HeaderMap, 132 116 Json(input): Json<CreateAppPasswordInput>, 133 117 ) -> Response { 134 - let auth_header = headers.get("Authorization"); 135 - if auth_header.is_none() { 136 - return ( 137 - StatusCode::UNAUTHORIZED, 138 - Json(json!({"error": "AuthenticationRequired"})), 139 - ) 140 - .into_response(); 141 - } 142 - 143 - let token = auth_header 144 - .unwrap() 145 - .to_str() 146 - .unwrap_or("") 147 - .replace("Bearer ", ""); 148 - 149 - let session = sqlx::query!( 150 - r#" 151 - SELECT s.did, k.key_bytes, u.id as user_id 152 - FROM sessions s 153 - JOIN users u ON s.did = u.did 154 - JOIN user_keys k ON u.id = k.user_id 155 - WHERE s.access_jwt = $1 156 - "#, 157 - token 158 - ) 159 - .fetch_optional(&state.db) 160 - .await; 161 - 162 - let (_did, key_bytes, user_id) = match session { 163 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 164 - Ok(None) => { 118 + let token = match crate::auth::extract_bearer_token_from_header( 119 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 120 + ) { 121 + Some(t) => t, 122 + None => { 165 123 return ( 166 124 StatusCode::UNAUTHORIZED, 167 - Json(json!({"error": "AuthenticationFailed"})), 125 + Json(json!({"error": "AuthenticationRequired"})), 168 126 ) 169 127 .into_response(); 170 128 } 129 + }; 130 + 131 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 132 + let did = match auth_result { 133 + Ok(user) => user.did, 171 134 Err(e) => { 172 - error!("DB error in create_app_password: {:?}", e); 135 + return ( 136 + StatusCode::UNAUTHORIZED, 137 + Json(json!({"error": e})), 138 + ) 139 + .into_response(); 140 + } 141 + }; 142 + 143 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 144 + .fetch_optional(&state.db) 145 + .await 146 + { 147 + Ok(Some(id)) => id, 148 + _ => { 173 149 return ( 174 150 StatusCode::INTERNAL_SERVER_ERROR, 175 151 Json(json!({"error": "InternalError"})), ··· 177 153 .into_response(); 178 154 } 179 155 }; 180 - 181 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 182 - return ( 183 - StatusCode::UNAUTHORIZED, 184 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 185 - ) 186 - .into_response(); 187 - } 188 156 189 157 let name = input.name.trim(); 190 158 if name.is_empty() { ··· 275 243 headers: axum::http::HeaderMap, 276 244 Json(input): Json<RevokeAppPasswordInput>, 277 245 ) -> Response { 278 - let auth_header = headers.get("Authorization"); 279 - if auth_header.is_none() { 280 - return ( 281 - StatusCode::UNAUTHORIZED, 282 - Json(json!({"error": "AuthenticationRequired"})), 283 - ) 284 - .into_response(); 285 - } 286 - 287 - let token = auth_header 288 - .unwrap() 289 - .to_str() 290 - .unwrap_or("") 291 - .replace("Bearer ", ""); 292 - 293 - let session = sqlx::query!( 294 - r#" 295 - SELECT s.did, k.key_bytes, u.id as user_id 296 - FROM sessions s 297 - JOIN users u ON s.did = u.did 298 - JOIN user_keys k ON u.id = k.user_id 299 - WHERE s.access_jwt = $1 300 - "#, 301 - token 302 - ) 303 - .fetch_optional(&state.db) 304 - .await; 305 - 306 - let (_did, key_bytes, user_id) = match session { 307 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 308 - Ok(None) => { 246 + let token = match crate::auth::extract_bearer_token_from_header( 247 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 248 + ) { 249 + Some(t) => t, 250 + None => { 309 251 return ( 310 252 StatusCode::UNAUTHORIZED, 311 - Json(json!({"error": "AuthenticationFailed"})), 253 + Json(json!({"error": "AuthenticationRequired"})), 312 254 ) 313 255 .into_response(); 314 256 } 257 + }; 258 + 259 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 260 + let did = match auth_result { 261 + Ok(user) => user.did, 315 262 Err(e) => { 316 - error!("DB error in revoke_app_password: {:?}", e); 263 + return ( 264 + StatusCode::UNAUTHORIZED, 265 + Json(json!({"error": e})), 266 + ) 267 + .into_response(); 268 + } 269 + }; 270 + 271 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 272 + .fetch_optional(&state.db) 273 + .await 274 + { 275 + Ok(Some(id)) => id, 276 + _ => { 317 277 return ( 318 278 StatusCode::INTERNAL_SERVER_ERROR, 319 279 Json(json!({"error": "InternalError"})), ··· 321 281 .into_response(); 322 282 } 323 283 }; 324 - 325 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 326 - return ( 327 - StatusCode::UNAUTHORIZED, 328 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 329 - ) 330 - .into_response(); 331 - } 332 284 333 285 let name = input.name.trim(); 334 286 if name.is_empty() {
+92 -148
src/api/server/email.rs
··· 30 30 headers: axum::http::HeaderMap, 31 31 Json(input): Json<RequestEmailUpdateInput>, 32 32 ) -> Response { 33 - let auth_header = headers.get("Authorization"); 34 - if auth_header.is_none() { 35 - return ( 36 - StatusCode::UNAUTHORIZED, 37 - Json(json!({"error": "AuthenticationRequired"})), 38 - ) 39 - .into_response(); 40 - } 41 - 42 - let token = auth_header 43 - .unwrap() 44 - .to_str() 45 - .unwrap_or("") 46 - .replace("Bearer ", ""); 47 - 48 - let session = sqlx::query!( 49 - r#" 50 - SELECT s.did, k.key_bytes, u.id as user_id, u.handle 51 - FROM sessions s 52 - JOIN users u ON s.did = u.did 53 - JOIN user_keys k ON u.id = k.user_id 54 - WHERE s.access_jwt = $1 55 - "#, 56 - token 57 - ) 58 - .fetch_optional(&state.db) 59 - .await; 60 - 61 - let (_did, key_bytes, user_id, handle) = match session { 62 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle), 63 - Ok(None) => { 33 + let token = match crate::auth::extract_bearer_token_from_header( 34 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 35 + ) { 36 + Some(t) => t, 37 + None => { 64 38 return ( 65 39 StatusCode::UNAUTHORIZED, 66 - Json(json!({"error": "AuthenticationFailed"})), 40 + Json(json!({"error": "AuthenticationRequired"})), 67 41 ) 68 42 .into_response(); 69 43 } 44 + }; 45 + 46 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 47 + let did = match auth_result { 48 + Ok(user) => user.did, 70 49 Err(e) => { 71 - error!("DB error in request_email_update: {:?}", e); 50 + return ( 51 + StatusCode::UNAUTHORIZED, 52 + Json(json!({"error": e})), 53 + ) 54 + .into_response(); 55 + } 56 + }; 57 + 58 + let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 59 + .fetch_optional(&state.db) 60 + .await 61 + { 62 + Ok(Some(row)) => row, 63 + _ => { 72 64 return ( 73 65 StatusCode::INTERNAL_SERVER_ERROR, 74 66 Json(json!({"error": "InternalError"})), ··· 76 68 .into_response(); 77 69 } 78 70 }; 79 - 80 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 81 - return ( 82 - StatusCode::UNAUTHORIZED, 83 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 84 - ) 85 - .into_response(); 86 - } 71 + let user_id = user.id; 72 + let handle = user.handle; 87 73 88 74 let email = input.email.trim().to_lowercase(); 89 75 if email.is_empty() { ··· 159 145 headers: axum::http::HeaderMap, 160 146 Json(input): Json<ConfirmEmailInput>, 161 147 ) -> Response { 162 - let auth_header = headers.get("Authorization"); 163 - if auth_header.is_none() { 164 - return ( 165 - StatusCode::UNAUTHORIZED, 166 - Json(json!({"error": "AuthenticationRequired"})), 167 - ) 168 - .into_response(); 169 - } 170 - 171 - let token = auth_header 172 - .unwrap() 173 - .to_str() 174 - .unwrap_or("") 175 - .replace("Bearer ", ""); 176 - 177 - let session = sqlx::query!( 178 - r#" 179 - 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 180 - FROM sessions s 181 - JOIN users u ON s.did = u.did 182 - JOIN user_keys k ON u.id = k.user_id 183 - WHERE s.access_jwt = $1 184 - "#, 185 - token 186 - ) 187 - .fetch_optional(&state.db) 188 - .await; 189 - 190 - let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session { 191 - Ok(Some(row)) => ( 192 - row.did, 193 - row.key_bytes, 194 - row.user_id, 195 - row.email_confirmation_code, 196 - row.email_confirmation_code_expires_at, 197 - row.email_pending_verification, 198 - ), 199 - Ok(None) => { 148 + let token = match crate::auth::extract_bearer_token_from_header( 149 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 150 + ) { 151 + Some(t) => t, 152 + None => { 200 153 return ( 201 154 StatusCode::UNAUTHORIZED, 202 - Json(json!({"error": "AuthenticationFailed"})), 155 + Json(json!({"error": "AuthenticationRequired"})), 203 156 ) 204 157 .into_response(); 205 158 } 159 + }; 160 + 161 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 162 + let did = match auth_result { 163 + Ok(user) => user.did, 206 164 Err(e) => { 207 - error!("DB error in confirm_email: {:?}", e); 165 + return ( 166 + StatusCode::UNAUTHORIZED, 167 + Json(json!({"error": e})), 168 + ) 169 + .into_response(); 170 + } 171 + }; 172 + 173 + let user = match sqlx::query!( 174 + "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 175 + did 176 + ) 177 + .fetch_optional(&state.db) 178 + .await 179 + { 180 + Ok(Some(row)) => row, 181 + _ => { 208 182 return ( 209 183 StatusCode::INTERNAL_SERVER_ERROR, 210 184 Json(json!({"error": "InternalError"})), ··· 212 186 .into_response(); 213 187 } 214 188 }; 215 - 216 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 217 - return ( 218 - StatusCode::UNAUTHORIZED, 219 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 220 - ) 221 - .into_response(); 222 - } 189 + let user_id = user.id; 190 + let stored_code = user.email_confirmation_code; 191 + let expires_at = user.email_confirmation_code_expires_at; 192 + let email_pending_verification = user.email_pending_verification; 223 193 224 194 let email = input.email.trim().to_lowercase(); 225 195 let confirmation_code = input.token.trim(); ··· 301 271 headers: axum::http::HeaderMap, 302 272 Json(input): Json<UpdateEmailInput>, 303 273 ) -> Response { 304 - let auth_header = headers.get("Authorization"); 305 - if auth_header.is_none() { 306 - return ( 307 - StatusCode::UNAUTHORIZED, 308 - Json(json!({"error": "AuthenticationRequired"})), 309 - ) 310 - .into_response(); 311 - } 312 - 313 - let token = auth_header 314 - .unwrap() 315 - .to_str() 316 - .unwrap_or("") 317 - .replace("Bearer ", ""); 318 - 319 - let session = sqlx::query!( 320 - r#" 321 - SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email, 322 - u.email_confirmation_code, u.email_confirmation_code_expires_at, 323 - u.email_pending_verification 324 - FROM sessions s 325 - JOIN users u ON s.did = u.did 326 - JOIN user_keys k ON u.id = k.user_id 327 - WHERE s.access_jwt = $1 328 - "#, 329 - token 330 - ) 331 - .fetch_optional(&state.db) 332 - .await; 333 - 334 - let ( 335 - _did, 336 - key_bytes, 337 - user_id, 338 - current_email, 339 - stored_code, 340 - expires_at, 341 - email_pending_verification, 342 - ) = match session { 343 - Ok(Some(row)) => ( 344 - row.did, 345 - row.key_bytes, 346 - row.user_id, 347 - row.current_email, 348 - row.email_confirmation_code, 349 - row.email_confirmation_code_expires_at, 350 - row.email_pending_verification, 351 - ), 352 - Ok(None) => { 274 + let token = match crate::auth::extract_bearer_token_from_header( 275 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 276 + ) { 277 + Some(t) => t, 278 + None => { 353 279 return ( 354 280 StatusCode::UNAUTHORIZED, 355 - Json(json!({"error": "AuthenticationFailed"})), 281 + Json(json!({"error": "AuthenticationRequired"})), 356 282 ) 357 283 .into_response(); 358 284 } 285 + }; 286 + 287 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 288 + let did = match auth_result { 289 + Ok(user) => user.did, 359 290 Err(e) => { 360 - error!("DB error in update_email: {:?}", e); 291 + return ( 292 + StatusCode::UNAUTHORIZED, 293 + Json(json!({"error": e})), 294 + ) 295 + .into_response(); 296 + } 297 + }; 298 + 299 + let user = match sqlx::query!( 300 + "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 301 + did 302 + ) 303 + .fetch_optional(&state.db) 304 + .await 305 + { 306 + Ok(Some(row)) => row, 307 + _ => { 361 308 return ( 362 309 StatusCode::INTERNAL_SERVER_ERROR, 363 310 Json(json!({"error": "InternalError"})), ··· 365 312 .into_response(); 366 313 } 367 314 }; 368 - 369 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 370 - return ( 371 - StatusCode::UNAUTHORIZED, 372 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 373 - ) 374 - .into_response(); 375 - } 315 + let user_id = user.id; 316 + let current_email = user.email; 317 + let stored_code = user.email_confirmation_code; 318 + let expires_at = user.email_confirmation_code_expires_at; 319 + let email_pending_verification = user.email_pending_verification; 376 320 377 321 let new_email = input.email.trim().to_lowercase(); 378 322 if new_email.is_empty() {
+75 -123
src/api/server/invite.rs
··· 27 27 headers: axum::http::HeaderMap, 28 28 Json(input): Json<CreateInviteCodeInput>, 29 29 ) -> Response { 30 - let auth_header = headers.get("Authorization"); 31 - if auth_header.is_none() { 32 - return ( 33 - StatusCode::UNAUTHORIZED, 34 - Json(json!({"error": "AuthenticationRequired"})), 35 - ) 36 - .into_response(); 37 - } 30 + let token = match crate::auth::extract_bearer_token_from_header( 31 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 32 + ) { 33 + Some(t) => t, 34 + None => { 35 + return ( 36 + StatusCode::UNAUTHORIZED, 37 + Json(json!({"error": "AuthenticationRequired"})), 38 + ) 39 + .into_response(); 40 + } 41 + }; 38 42 39 43 if input.use_count < 1 { 40 44 return ( ··· 44 48 .into_response(); 45 49 } 46 50 47 - let token = auth_header 48 - .unwrap() 49 - .to_str() 50 - .unwrap_or("") 51 - .replace("Bearer ", ""); 52 - 53 - let session = sqlx::query!( 54 - r#" 55 - SELECT s.did, k.key_bytes, u.id as user_id 56 - FROM sessions s 57 - JOIN users u ON s.did = u.did 58 - JOIN user_keys k ON u.id = k.user_id 59 - WHERE s.access_jwt = $1 60 - "#, 61 - token 62 - ) 63 - .fetch_optional(&state.db) 64 - .await; 65 - 66 - let (did, key_bytes, user_id) = match session { 67 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 68 - Ok(None) => { 51 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 52 + let did = match auth_result { 53 + Ok(user) => user.did, 54 + Err(e) => { 69 55 return ( 70 56 StatusCode::UNAUTHORIZED, 71 - Json(json!({"error": "AuthenticationFailed"})), 57 + Json(json!({"error": e})), 72 58 ) 73 59 .into_response(); 74 60 } 75 - Err(e) => { 76 - error!("DB error in create_invite_code: {:?}", e); 61 + }; 62 + 63 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 64 + .fetch_optional(&state.db) 65 + .await 66 + { 67 + Ok(Some(id)) => id, 68 + _ => { 77 69 return ( 78 70 StatusCode::INTERNAL_SERVER_ERROR, 79 71 Json(json!({"error": "InternalError"})), ··· 81 73 .into_response(); 82 74 } 83 75 }; 84 - 85 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 86 - return ( 87 - StatusCode::UNAUTHORIZED, 88 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 89 - ) 90 - .into_response(); 91 - } 92 76 93 77 let creator_user_id = if let Some(for_account) = &input.for_account { 94 78 let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) ··· 184 168 headers: axum::http::HeaderMap, 185 169 Json(input): Json<CreateInviteCodesInput>, 186 170 ) -> Response { 187 - let auth_header = headers.get("Authorization"); 188 - if auth_header.is_none() { 189 - return ( 190 - StatusCode::UNAUTHORIZED, 191 - Json(json!({"error": "AuthenticationRequired"})), 192 - ) 193 - .into_response(); 194 - } 171 + let token = match crate::auth::extract_bearer_token_from_header( 172 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 173 + ) { 174 + Some(t) => t, 175 + None => { 176 + return ( 177 + StatusCode::UNAUTHORIZED, 178 + Json(json!({"error": "AuthenticationRequired"})), 179 + ) 180 + .into_response(); 181 + } 182 + }; 195 183 196 184 if input.use_count < 1 { 197 185 return ( ··· 201 189 .into_response(); 202 190 } 203 191 204 - let token = auth_header 205 - .unwrap() 206 - .to_str() 207 - .unwrap_or("") 208 - .replace("Bearer ", ""); 209 - 210 - let session = sqlx::query!( 211 - r#" 212 - SELECT s.did, k.key_bytes, u.id as user_id 213 - FROM sessions s 214 - JOIN users u ON s.did = u.did 215 - JOIN user_keys k ON u.id = k.user_id 216 - WHERE s.access_jwt = $1 217 - "#, 218 - token 219 - ) 220 - .fetch_optional(&state.db) 221 - .await; 222 - 223 - let (_did, key_bytes, user_id) = match session { 224 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 225 - Ok(None) => { 192 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 193 + let did = match auth_result { 194 + Ok(user) => user.did, 195 + Err(e) => { 226 196 return ( 227 197 StatusCode::UNAUTHORIZED, 228 - Json(json!({"error": "AuthenticationFailed"})), 198 + Json(json!({"error": e})), 229 199 ) 230 200 .into_response(); 231 201 } 232 - Err(e) => { 233 - error!("DB error in create_invite_codes: {:?}", e); 202 + }; 203 + 204 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 205 + .fetch_optional(&state.db) 206 + .await 207 + { 208 + Ok(Some(id)) => id, 209 + _ => { 234 210 return ( 235 211 StatusCode::INTERNAL_SERVER_ERROR, 236 212 Json(json!({"error": "InternalError"})), ··· 238 214 .into_response(); 239 215 } 240 216 }; 241 - 242 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 243 - return ( 244 - StatusCode::UNAUTHORIZED, 245 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 246 - ) 247 - .into_response(); 248 - } 249 217 250 218 let code_count = input.code_count.unwrap_or(1).max(1); 251 219 let for_accounts = input.for_accounts.unwrap_or_default(); ··· 374 342 headers: axum::http::HeaderMap, 375 343 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 376 344 ) -> Response { 377 - let auth_header = headers.get("Authorization"); 378 - if auth_header.is_none() { 379 - return ( 380 - StatusCode::UNAUTHORIZED, 381 - Json(json!({"error": "AuthenticationRequired"})), 382 - ) 383 - .into_response(); 384 - } 385 - 386 - let token = auth_header 387 - .unwrap() 388 - .to_str() 389 - .unwrap_or("") 390 - .replace("Bearer ", ""); 391 - 392 - let session = sqlx::query!( 393 - r#" 394 - SELECT s.did, k.key_bytes, u.id as user_id 395 - FROM sessions s 396 - JOIN users u ON s.did = u.did 397 - JOIN user_keys k ON u.id = k.user_id 398 - WHERE s.access_jwt = $1 399 - "#, 400 - token 401 - ) 402 - .fetch_optional(&state.db) 403 - .await; 404 - 405 - let (did, key_bytes, user_id) = match session { 406 - Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), 407 - Ok(None) => { 345 + let token = match crate::auth::extract_bearer_token_from_header( 346 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 347 + ) { 348 + Some(t) => t, 349 + None => { 408 350 return ( 409 351 StatusCode::UNAUTHORIZED, 410 - Json(json!({"error": "AuthenticationFailed"})), 352 + Json(json!({"error": "AuthenticationRequired"})), 411 353 ) 412 354 .into_response(); 413 355 } 356 + }; 357 + 358 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 359 + let did = match auth_result { 360 + Ok(user) => user.did, 414 361 Err(e) => { 415 - error!("DB error in get_account_invite_codes: {:?}", e); 362 + return ( 363 + StatusCode::UNAUTHORIZED, 364 + Json(json!({"error": e})), 365 + ) 366 + .into_response(); 367 + } 368 + }; 369 + 370 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 371 + .fetch_optional(&state.db) 372 + .await 373 + { 374 + Ok(Some(id)) => id, 375 + _ => { 416 376 return ( 417 377 StatusCode::INTERNAL_SERVER_ERROR, 418 378 Json(json!({"error": "InternalError"})), ··· 420 380 .into_response(); 421 381 } 422 382 }; 423 - 424 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 425 - return ( 426 - StatusCode::UNAUTHORIZED, 427 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 428 - ) 429 - .into_response(); 430 - } 431 383 432 384 let include_used = params.include_used.unwrap_or(true); 433 385
+1 -1
src/api/server/password.rs
··· 211 211 .into_response(); 212 212 } 213 213 214 - let _ = sqlx::query!("DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) 214 + let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) 215 215 .execute(&state.db) 216 216 .await; 217 217
+314 -161
src/api/server/session.rs
··· 27 27 headers: axum::http::HeaderMap, 28 28 Query(params): Query<GetServiceAuthParams>, 29 29 ) -> Response { 30 - let auth_header = headers.get("Authorization"); 31 - if auth_header.is_none() { 32 - return ( 33 - StatusCode::UNAUTHORIZED, 34 - Json(json!({"error": "AuthenticationRequired"})), 35 - ) 36 - .into_response(); 37 - } 38 - 39 - let token = auth_header 40 - .unwrap() 41 - .to_str() 42 - .unwrap_or("") 43 - .replace("Bearer ", ""); 44 - 45 - let session = sqlx::query!( 46 - r#" 47 - SELECT s.did, k.key_bytes 48 - FROM sessions s 49 - JOIN users u ON s.did = u.did 50 - JOIN user_keys k ON u.id = k.user_id 51 - WHERE s.access_jwt = $1 52 - "#, 53 - token 54 - ) 55 - .fetch_optional(&state.db) 56 - .await; 57 - 58 - let (did, key_bytes) = match session { 59 - Ok(Some(row)) => (row.did, row.key_bytes), 60 - Ok(None) => { 30 + let token = match crate::auth::extract_bearer_token_from_header( 31 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 32 + ) { 33 + Some(t) => t, 34 + None => { 61 35 return ( 62 36 StatusCode::UNAUTHORIZED, 63 - Json(json!({"error": "AuthenticationFailed"})), 37 + Json(json!({"error": "AuthenticationRequired"})), 64 38 ) 65 39 .into_response(); 66 40 } 41 + }; 42 + 43 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 44 + let (did, key_bytes) = match auth_result { 45 + Ok(user) => { 46 + let kb = match user.key_bytes { 47 + Some(kb) => kb, 48 + None => { 49 + return ( 50 + StatusCode::UNAUTHORIZED, 51 + Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot create service auth"})), 52 + ) 53 + .into_response(); 54 + } 55 + }; 56 + (user.did, kb) 57 + } 67 58 Err(e) => { 68 - error!("DB error in get_service_auth: {:?}", e); 69 59 return ( 70 - StatusCode::INTERNAL_SERVER_ERROR, 71 - Json(json!({"error": "InternalError"})), 60 + StatusCode::UNAUTHORIZED, 61 + Json(json!({"error": e})), 72 62 ) 73 63 .into_response(); 74 64 } 75 65 }; 76 66 77 - if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 78 - return ( 79 - StatusCode::UNAUTHORIZED, 80 - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 81 - ) 82 - .into_response(); 83 - } 84 - 85 67 let lxm = params.lxm.as_deref().unwrap_or("*"); 86 68 87 69 let service_token = match crate::auth::create_service_token(&did, &params.aud, lxm, &key_bytes) ··· 122 104 info!("create_session: identifier='{}'", input.identifier); 123 105 124 106 let user_row = sqlx::query!( 125 - "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", 107 + "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", 126 108 input.identifier 127 109 ) 128 110 .fetch_optional(&state.db) ··· 134 116 let stored_hash = &row.password_hash; 135 117 let did = &row.did; 136 118 let handle = &row.handle; 137 - let key_bytes = &row.key_bytes; 119 + let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 120 + Ok(k) => k, 121 + Err(e) => { 122 + error!("Failed to decrypt user key: {:?}", e); 123 + return ( 124 + StatusCode::INTERNAL_SERVER_ERROR, 125 + Json(json!({"error": "InternalError"})), 126 + ) 127 + .into_response(); 128 + } 129 + }; 138 130 139 131 let password_valid = if verify(&input.password, stored_hash).unwrap_or(false) { 140 132 true ··· 150 142 }; 151 143 152 144 if password_valid { 153 - let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { 154 - Ok(t) => t, 145 + let access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { 146 + Ok(m) => m, 155 147 Err(e) => { 156 148 error!("Failed to create access token: {:?}", e); 157 149 return ( ··· 162 154 } 163 155 }; 164 156 165 - let refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { 166 - Ok(t) => t, 157 + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { 158 + Ok(m) => m, 167 159 Err(e) => { 168 160 error!("Failed to create refresh token: {:?}", e); 169 161 return ( ··· 175 167 }; 176 168 177 169 let session_insert = sqlx::query!( 178 - "INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", 179 - access_jwt, 180 - refresh_jwt, 181 - did 170 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 171 + did, 172 + access_meta.jti, 173 + refresh_meta.jti, 174 + access_meta.expires_at, 175 + refresh_meta.expires_at 182 176 ) 183 177 .execute(&state.db) 184 178 .await; ··· 188 182 return ( 189 183 StatusCode::OK, 190 184 Json(CreateSessionOutput { 191 - access_jwt, 192 - refresh_jwt, 185 + access_jwt: access_meta.token, 186 + refresh_jwt: refresh_meta.token, 193 187 handle: handle.clone(), 194 188 did: did.clone(), 195 189 }), ··· 236 230 State(state): State<AppState>, 237 231 headers: axum::http::HeaderMap, 238 232 ) -> Response { 239 - let auth_header = headers.get("Authorization"); 240 - if auth_header.is_none() { 241 - return ( 242 - StatusCode::UNAUTHORIZED, 243 - Json(json!({"error": "AuthenticationRequired"})), 244 - ) 245 - .into_response(); 246 - } 233 + let token = match crate::auth::extract_bearer_token_from_header( 234 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 235 + ) { 236 + Some(t) => t, 237 + None => { 238 + return ( 239 + StatusCode::UNAUTHORIZED, 240 + Json(json!({"error": "AuthenticationRequired", "message": "Invalid Authorization header format"})), 241 + ) 242 + .into_response(); 243 + } 244 + }; 247 245 248 - let token = auth_header 249 - .unwrap() 250 - .to_str() 251 - .unwrap_or("") 252 - .replace("Bearer ", ""); 246 + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 247 + let did = match auth_result { 248 + Ok(user) => user.did, 249 + Err(e) => { 250 + return ( 251 + StatusCode::UNAUTHORIZED, 252 + Json(json!({"error": e})), 253 + ) 254 + .into_response(); 255 + } 256 + }; 253 257 254 - let result = sqlx::query!( 255 - r#" 256 - SELECT u.handle, u.did, u.email, k.key_bytes 257 - FROM sessions s 258 - JOIN users u ON s.did = u.did 259 - JOIN user_keys k ON u.id = k.user_id 260 - WHERE s.access_jwt = $1 261 - "#, 262 - token 258 + let user = sqlx::query!( 259 + "SELECT handle, email FROM users WHERE did = $1", 260 + did 263 261 ) 264 262 .fetch_optional(&state.db) 265 263 .await; 266 264 267 - match result { 265 + match user { 268 266 Ok(Some(row)) => { 269 - if let Err(_) = crate::auth::verify_token(&token, &row.key_bytes) { 270 - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"}))).into_response(); 271 - } 272 - 273 267 return ( 274 268 StatusCode::OK, 275 269 Json(json!({ 276 270 "handle": row.handle, 277 - "did": row.did, 271 + "did": did, 278 272 "email": row.email, 279 273 "didDoc": {} 280 274 })), ··· 303 297 State(state): State<AppState>, 304 298 headers: axum::http::HeaderMap, 305 299 ) -> Response { 306 - let auth_header = headers.get("Authorization"); 307 - if auth_header.is_none() { 308 - return ( 309 - StatusCode::UNAUTHORIZED, 310 - Json(json!({"error": "AuthenticationRequired"})), 311 - ) 312 - .into_response(); 313 - } 300 + let token = match crate::auth::extract_bearer_token_from_header( 301 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 302 + ) { 303 + Some(t) => t, 304 + None => { 305 + return ( 306 + StatusCode::UNAUTHORIZED, 307 + Json(json!({"error": "AuthenticationRequired"})), 308 + ) 309 + .into_response(); 310 + } 311 + }; 314 312 315 - let token = auth_header 316 - .unwrap() 317 - .to_str() 318 - .unwrap_or("") 319 - .replace("Bearer ", ""); 313 + let jti = match crate::auth::get_did_from_token(&token) { 314 + Ok(_) => { 315 + let parts: Vec<&str> = token.split('.').collect(); 316 + if parts.len() != 3 { 317 + return ( 318 + StatusCode::UNAUTHORIZED, 319 + Json(json!({"error": "AuthenticationFailed"})), 320 + ) 321 + .into_response(); 322 + } 323 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 324 + let claims_json = match URL_SAFE_NO_PAD.decode(parts[1]) { 325 + Ok(bytes) => bytes, 326 + Err(_) => { 327 + return ( 328 + StatusCode::UNAUTHORIZED, 329 + Json(json!({"error": "AuthenticationFailed"})), 330 + ) 331 + .into_response(); 332 + } 333 + }; 334 + let claims: serde_json::Value = match serde_json::from_slice(&claims_json) { 335 + Ok(c) => c, 336 + Err(_) => { 337 + return ( 338 + StatusCode::UNAUTHORIZED, 339 + Json(json!({"error": "AuthenticationFailed"})), 340 + ) 341 + .into_response(); 342 + } 343 + }; 344 + match claims.get("jti").and_then(|j| j.as_str()) { 345 + Some(jti) => jti.to_string(), 346 + None => { 347 + return ( 348 + StatusCode::UNAUTHORIZED, 349 + Json(json!({"error": "AuthenticationFailed"})), 350 + ) 351 + .into_response(); 352 + } 353 + } 354 + } 355 + Err(_) => { 356 + return ( 357 + StatusCode::UNAUTHORIZED, 358 + Json(json!({"error": "AuthenticationFailed"})), 359 + ) 360 + .into_response(); 361 + } 362 + }; 320 363 321 - let result = sqlx::query!("DELETE FROM sessions WHERE access_jwt = $1", token) 364 + let result = sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) 322 365 .execute(&state.db) 323 366 .await; 324 367 ··· 344 387 State(state): State<AppState>, 345 388 headers: axum::http::HeaderMap, 346 389 ) -> Response { 347 - let auth_header = headers.get("Authorization"); 348 - if auth_header.is_none() { 390 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 391 + 392 + let refresh_token = match crate::auth::extract_bearer_token_from_header( 393 + headers.get("Authorization").and_then(|h| h.to_str().ok()) 394 + ) { 395 + Some(t) => t, 396 + None => { 397 + return ( 398 + StatusCode::UNAUTHORIZED, 399 + Json(json!({"error": "AuthenticationRequired"})), 400 + ) 401 + .into_response(); 402 + } 403 + }; 404 + 405 + let refresh_jti = { 406 + let parts: Vec<&str> = refresh_token.split('.').collect(); 407 + if parts.len() != 3 { 408 + return ( 409 + StatusCode::UNAUTHORIZED, 410 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token format"})), 411 + ) 412 + .into_response(); 413 + } 414 + let claims_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) { 415 + Ok(b) => b, 416 + Err(_) => { 417 + return ( 418 + StatusCode::UNAUTHORIZED, 419 + Json(json!({"error": "AuthenticationFailed"})), 420 + ) 421 + .into_response(); 422 + } 423 + }; 424 + let claims: serde_json::Value = match serde_json::from_slice(&claims_bytes) { 425 + Ok(c) => c, 426 + Err(_) => { 427 + return ( 428 + StatusCode::UNAUTHORIZED, 429 + Json(json!({"error": "AuthenticationFailed"})), 430 + ) 431 + .into_response(); 432 + } 433 + }; 434 + match claims.get("jti").and_then(|j| j.as_str()) { 435 + Some(jti) => jti.to_string(), 436 + None => { 437 + return ( 438 + StatusCode::UNAUTHORIZED, 439 + Json(json!({"error": "AuthenticationFailed"})), 440 + ) 441 + .into_response(); 442 + } 443 + } 444 + }; 445 + 446 + let reuse_check = sqlx::query_scalar!( 447 + "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", 448 + refresh_jti 449 + ) 450 + .fetch_optional(&state.db) 451 + .await; 452 + 453 + if let Ok(Some(session_id)) = reuse_check { 454 + warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id); 455 + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) 456 + .execute(&state.db) 457 + .await; 349 458 return ( 350 459 StatusCode::UNAUTHORIZED, 351 - Json(json!({"error": "AuthenticationRequired"})), 460 + Json(json!({"error": "ExpiredToken", "message": "Refresh token has been revoked due to suspected compromise"})), 352 461 ) 353 462 .into_response(); 354 463 } 355 464 356 - let refresh_token = auth_header 357 - .unwrap() 358 - .to_str() 359 - .unwrap_or("") 360 - .replace("Bearer ", ""); 361 - 362 465 let session = sqlx::query!( 363 - "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", 364 - refresh_token 365 - ) 366 - .fetch_optional(&state.db) 367 - .await; 466 + r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version 467 + FROM session_tokens st 468 + JOIN users u ON st.did = u.did 469 + JOIN user_keys k ON u.id = k.user_id 470 + WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()"#, 471 + refresh_jti 472 + ) 473 + .fetch_optional(&state.db) 474 + .await; 368 475 369 476 match session { 370 477 Ok(Some(session_row)) => { 478 + let session_id = session_row.id; 371 479 let did = &session_row.did; 372 - let key_bytes = &session_row.key_bytes; 480 + let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { 481 + Ok(k) => k, 482 + Err(e) => { 483 + error!("Failed to decrypt user key: {:?}", e); 484 + return ( 485 + StatusCode::INTERNAL_SERVER_ERROR, 486 + Json(json!({"error": "InternalError"})), 487 + ) 488 + .into_response(); 489 + } 490 + }; 373 491 374 - if let Err(_) = crate::auth::verify_token(&refresh_token, &key_bytes) { 375 - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token signature"}))).into_response(); 492 + if let Err(_) = crate::auth::verify_refresh_token(&refresh_token, &key_bytes) { 493 + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"}))).into_response(); 376 494 } 377 495 378 - let new_access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { 379 - Ok(t) => t, 496 + let new_access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { 497 + Ok(m) => m, 380 498 Err(e) => { 381 499 error!("Failed to create access token: {:?}", e); 382 500 return ( ··· 386 504 .into_response(); 387 505 } 388 506 }; 389 - let new_refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) { 390 - Ok(t) => t, 507 + let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { 508 + Ok(m) => m, 391 509 Err(e) => { 392 510 error!("Failed to create refresh token: {:?}", e); 393 511 return ( ··· 398 516 } 399 517 }; 400 518 401 - let update = sqlx::query!( 402 - "UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3", 403 - new_access_jwt, 404 - new_refresh_jwt, 405 - refresh_token 519 + let mut tx = match state.db.begin().await { 520 + Ok(tx) => tx, 521 + Err(e) => { 522 + error!("Failed to begin transaction: {:?}", e); 523 + return ( 524 + StatusCode::INTERNAL_SERVER_ERROR, 525 + Json(json!({"error": "InternalError"})), 526 + ) 527 + .into_response(); 528 + } 529 + }; 530 + 531 + if let Err(e) = sqlx::query!( 532 + "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", 533 + refresh_jti, 534 + session_id 406 535 ) 407 - .execute(&state.db) 408 - .await; 536 + .execute(&mut *tx) 537 + .await 538 + { 539 + error!("Failed to record used refresh token: {:?}", e); 540 + return ( 541 + StatusCode::INTERNAL_SERVER_ERROR, 542 + Json(json!({"error": "InternalError"})), 543 + ) 544 + .into_response(); 545 + } 409 546 410 - match update { 411 - Ok(_) => { 412 - let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did) 413 - .fetch_optional(&state.db) 414 - .await; 547 + if let Err(e) = sqlx::query!( 548 + "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", 549 + new_access_meta.jti, 550 + new_refresh_meta.jti, 551 + new_access_meta.expires_at, 552 + new_refresh_meta.expires_at, 553 + session_id 554 + ) 555 + .execute(&mut *tx) 556 + .await 557 + { 558 + error!("Database error updating session: {:?}", e); 559 + return ( 560 + StatusCode::INTERNAL_SERVER_ERROR, 561 + Json(json!({"error": "InternalError"})), 562 + ) 563 + .into_response(); 564 + } 415 565 416 - match user { 417 - Ok(Some(u)) => { 418 - return ( 419 - StatusCode::OK, 420 - Json(json!({ 421 - "accessJwt": new_access_jwt, 422 - "refreshJwt": new_refresh_jwt, 423 - "handle": u.handle, 424 - "did": did 425 - })), 426 - ) 427 - .into_response(); 428 - } 429 - Ok(None) => { 430 - error!("User not found for existing session: {}", did); 431 - return ( 432 - StatusCode::INTERNAL_SERVER_ERROR, 433 - Json(json!({"error": "InternalError"})), 434 - ) 435 - .into_response(); 436 - } 437 - Err(e) => { 438 - error!("Database error fetching user: {:?}", e); 439 - return ( 440 - StatusCode::INTERNAL_SERVER_ERROR, 441 - Json(json!({"error": "InternalError"})), 442 - ) 443 - .into_response(); 444 - } 445 - } 566 + if let Err(e) = tx.commit().await { 567 + error!("Failed to commit transaction: {:?}", e); 568 + return ( 569 + StatusCode::INTERNAL_SERVER_ERROR, 570 + Json(json!({"error": "InternalError"})), 571 + ) 572 + .into_response(); 573 + } 574 + 575 + let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did) 576 + .fetch_optional(&state.db) 577 + .await; 578 + 579 + match user { 580 + Ok(Some(u)) => { 581 + return ( 582 + StatusCode::OK, 583 + Json(json!({ 584 + "accessJwt": new_access_meta.token, 585 + "refreshJwt": new_refresh_meta.token, 586 + "handle": u.handle, 587 + "did": did 588 + })), 589 + ) 590 + .into_response(); 591 + } 592 + Ok(None) => { 593 + error!("User not found for existing session: {}", did); 594 + return ( 595 + StatusCode::INTERNAL_SERVER_ERROR, 596 + Json(json!({"error": "InternalError"})), 597 + ) 598 + .into_response(); 446 599 } 447 600 Err(e) => { 448 - error!("Database error updating session: {:?}", e); 601 + error!("Database error fetching user: {:?}", e); 449 602 return ( 450 603 StatusCode::INTERNAL_SERVER_ERROR, 451 604 Json(json!({"error": "InternalError"})),
+140
src/auth/extractor.rs
··· 1 + use axum::{ 2 + extract::FromRequestParts, 3 + http::{StatusCode, request::Parts, header::AUTHORIZATION}, 4 + response::{IntoResponse, Response}, 5 + Json, 6 + }; 7 + use serde_json::json; 8 + 9 + use crate::state::AppState; 10 + use super::{AuthenticatedUser, validate_bearer_token}; 11 + 12 + pub struct BearerAuth(pub AuthenticatedUser); 13 + 14 + #[derive(Debug)] 15 + pub enum AuthError { 16 + MissingToken, 17 + InvalidFormat, 18 + AuthenticationFailed, 19 + AccountDeactivated, 20 + AccountTakedown, 21 + } 22 + 23 + impl IntoResponse for AuthError { 24 + fn into_response(self) -> Response { 25 + let (status, error, message) = match self { 26 + AuthError::MissingToken => ( 27 + StatusCode::UNAUTHORIZED, 28 + "AuthenticationRequired", 29 + "Authorization header is required", 30 + ), 31 + AuthError::InvalidFormat => ( 32 + StatusCode::UNAUTHORIZED, 33 + "InvalidToken", 34 + "Invalid authorization header format", 35 + ), 36 + AuthError::AuthenticationFailed => ( 37 + StatusCode::UNAUTHORIZED, 38 + "AuthenticationFailed", 39 + "Invalid or expired token", 40 + ), 41 + AuthError::AccountDeactivated => ( 42 + StatusCode::UNAUTHORIZED, 43 + "AccountDeactivated", 44 + "Account is deactivated", 45 + ), 46 + AuthError::AccountTakedown => ( 47 + StatusCode::UNAUTHORIZED, 48 + "AccountTakedown", 49 + "Account has been taken down", 50 + ), 51 + }; 52 + 53 + (status, Json(json!({ "error": error, "message": message }))).into_response() 54 + } 55 + } 56 + 57 + fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> { 58 + let auth_header = auth_header.trim(); 59 + 60 + if auth_header.len() < 8 { 61 + return Err(AuthError::InvalidFormat); 62 + } 63 + 64 + let prefix = &auth_header[..7]; 65 + if !prefix.eq_ignore_ascii_case("bearer ") { 66 + return Err(AuthError::InvalidFormat); 67 + } 68 + 69 + let token = auth_header[7..].trim(); 70 + if token.is_empty() { 71 + return Err(AuthError::InvalidFormat); 72 + } 73 + 74 + Ok(token) 75 + } 76 + 77 + pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> { 78 + let header = auth_header?; 79 + let header = header.trim(); 80 + 81 + if header.len() < 7 { 82 + return None; 83 + } 84 + 85 + if !header[..7].eq_ignore_ascii_case("bearer ") { 86 + return None; 87 + } 88 + 89 + let token = header[7..].trim(); 90 + if token.is_empty() { 91 + return None; 92 + } 93 + 94 + Some(token.to_string()) 95 + } 96 + 97 + impl FromRequestParts<AppState> for BearerAuth { 98 + type Rejection = AuthError; 99 + 100 + async fn from_request_parts( 101 + parts: &mut Parts, 102 + state: &AppState, 103 + ) -> Result<Self, Self::Rejection> { 104 + let auth_header = parts 105 + .headers 106 + .get(AUTHORIZATION) 107 + .ok_or(AuthError::MissingToken)? 108 + .to_str() 109 + .map_err(|_| AuthError::InvalidFormat)?; 110 + 111 + let token = extract_bearer_token(auth_header)?; 112 + 113 + match validate_bearer_token(&state.db, token).await { 114 + Ok(user) => Ok(BearerAuth(user)), 115 + Err("AccountDeactivated") => Err(AuthError::AccountDeactivated), 116 + Err("AccountTakedown") => Err(AuthError::AccountTakedown), 117 + Err(_) => Err(AuthError::AuthenticationFailed), 118 + } 119 + } 120 + } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use super::*; 125 + 126 + #[test] 127 + fn test_extract_bearer_token() { 128 + assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123"); 129 + assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123"); 130 + assert_eq!(extract_bearer_token("BEARER abc123").unwrap(), "abc123"); 131 + assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123"); 132 + assert_eq!(extract_bearer_token(" Bearer abc123 ").unwrap(), "abc123"); 133 + 134 + assert!(extract_bearer_token("Basic abc123").is_err()); 135 + assert!(extract_bearer_token("Bearer").is_err()); 136 + assert!(extract_bearer_token("Bearer ").is_err()); 137 + assert!(extract_bearer_token("abc123").is_err()); 138 + assert!(extract_bearer_token("").is_err()); 139 + } 140 + }
+119 -2
src/auth/mod.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 + use sqlx::PgPool; 2 3 4 + pub mod extractor; 3 5 pub mod token; 4 6 pub mod verify; 5 7 6 - pub use token::{create_access_token, create_refresh_token, create_service_token}; 7 - pub use verify::{get_did_from_token, verify_token}; 8 + pub use extractor::{BearerAuth, AuthError, extract_bearer_token_from_header}; 9 + pub use token::{ 10 + create_access_token, create_refresh_token, create_service_token, 11 + create_access_token_with_metadata, create_refresh_token_with_metadata, 12 + TokenWithMetadata, 13 + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, 14 + SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, 15 + }; 16 + pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token}; 17 + 18 + pub struct AuthenticatedUser { 19 + pub did: String, 20 + pub key_bytes: Option<Vec<u8>>, 21 + pub is_oauth: bool, 22 + } 23 + 24 + pub async fn validate_bearer_token( 25 + db: &PgPool, 26 + token: &str, 27 + ) -> Result<AuthenticatedUser, &'static str> { 28 + validate_bearer_token_with_options(db, token, false).await 29 + } 30 + 31 + pub async fn validate_bearer_token_allow_deactivated( 32 + db: &PgPool, 33 + token: &str, 34 + ) -> Result<AuthenticatedUser, &'static str> { 35 + validate_bearer_token_with_options(db, token, true).await 36 + } 37 + 38 + async fn validate_bearer_token_with_options( 39 + db: &PgPool, 40 + token: &str, 41 + allow_deactivated: bool, 42 + ) -> Result<AuthenticatedUser, &'static str> { 43 + let did_from_token = get_did_from_token(token).ok(); 44 + 45 + if let Some(ref did) = did_from_token { 46 + if let Some(user) = sqlx::query!( 47 + "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref 48 + FROM users u 49 + JOIN user_keys k ON u.id = k.user_id 50 + WHERE u.did = $1", 51 + did 52 + ) 53 + .fetch_optional(db) 54 + .await 55 + .ok() 56 + .flatten() 57 + { 58 + if !allow_deactivated && user.deactivated_at.is_some() { 59 + return Err("AccountDeactivated"); 60 + } 61 + if user.takedown_ref.is_some() { 62 + return Err("AccountTakedown"); 63 + } 64 + 65 + let decrypted_key = match crate::config::decrypt_key(&user.key_bytes, user.encryption_version) { 66 + Ok(k) => k, 67 + Err(_) => return Err("KeyDecryptionFailed"), 68 + }; 69 + 70 + if let Ok(token_data) = verify_access_token(token, &decrypted_key) { 71 + let session_exists = sqlx::query_scalar!( 72 + "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", 73 + did, 74 + token_data.claims.jti 75 + ) 76 + .fetch_optional(db) 77 + .await 78 + .ok() 79 + .flatten(); 80 + 81 + if session_exists.is_some() { 82 + return Ok(AuthenticatedUser { 83 + did: did.clone(), 84 + key_bytes: Some(decrypted_key), 85 + is_oauth: false, 86 + }); 87 + } 88 + } 89 + } 90 + } 91 + 92 + if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) { 93 + if let Some(oauth_token) = sqlx::query!( 94 + r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref 95 + FROM oauth_token t 96 + JOIN users u ON t.did = u.did 97 + WHERE t.token_id = $1"#, 98 + oauth_info.token_id 99 + ) 100 + .fetch_optional(db) 101 + .await 102 + .ok() 103 + .flatten() 104 + { 105 + if !allow_deactivated && oauth_token.deactivated_at.is_some() { 106 + return Err("AccountDeactivated"); 107 + } 108 + if oauth_token.takedown_ref.is_some() { 109 + return Err("AccountTakedown"); 110 + } 111 + 112 + let now = chrono::Utc::now(); 113 + if oauth_token.expires_at > now { 114 + return Ok(AuthenticatedUser { 115 + did: oauth_token.did, 116 + key_bytes: None, 117 + is_oauth: true, 118 + }); 119 + } 120 + } 121 + } 122 + 123 + Err("AuthenticationFailed") 124 + } 8 125 9 126 #[derive(Debug, Serialize, Deserialize)] 10 127 pub struct Claims {
+45 -11
src/auth/token.rs
··· 2 2 use anyhow::Result; 3 3 use base64::Engine as _; 4 4 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 5 - use chrono::{Duration, Utc}; 5 + use chrono::{DateTime, Duration, Utc}; 6 6 use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 7 7 use uuid; 8 8 9 + pub const TOKEN_TYPE_ACCESS: &str = "at+jwt"; 10 + pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt"; 11 + pub const TOKEN_TYPE_SERVICE: &str = "jwt"; 12 + 13 + pub const SCOPE_ACCESS: &str = "com.atproto.access"; 14 + pub const SCOPE_REFRESH: &str = "com.atproto.refresh"; 15 + pub const SCOPE_APP_PASS: &str = "com.atproto.appPass"; 16 + pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged"; 17 + 18 + pub struct TokenWithMetadata { 19 + pub token: String, 20 + pub jti: String, 21 + pub expires_at: DateTime<Utc>, 22 + } 23 + 9 24 pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> { 10 - create_signed_token(did, "access", key_bytes, Duration::minutes(15)) 25 + Ok(create_access_token_with_metadata(did, key_bytes)?.token) 11 26 } 12 27 13 28 pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> { 14 - create_signed_token(did, "refresh", key_bytes, Duration::days(7)) 29 + Ok(create_refresh_token_with_metadata(did, key_bytes)?.token) 30 + } 31 + 32 + pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> { 33 + create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120)) 34 + } 35 + 36 + pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> { 37 + create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90)) 15 38 } 16 39 17 40 pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> { ··· 36 59 sign_claims(claims, &signing_key) 37 60 } 38 61 39 - fn create_signed_token( 62 + fn create_signed_token_with_metadata( 40 63 did: &str, 41 64 scope: &str, 65 + typ: &str, 42 66 key_bytes: &[u8], 43 67 duration: Duration, 44 - ) -> Result<String> { 68 + ) -> Result<TokenWithMetadata> { 45 69 let signing_key = SigningKey::from_slice(key_bytes)?; 46 70 47 - let expiration = Utc::now() 71 + let expires_at = Utc::now() 48 72 .checked_add_signed(duration) 49 - .expect("valid timestamp") 50 - .timestamp(); 73 + .expect("valid timestamp"); 74 + let expiration = expires_at.timestamp(); 75 + let jti = uuid::Uuid::new_v4().to_string(); 51 76 52 77 let claims = Claims { 53 78 iss: did.to_owned(), ··· 60 85 iat: Utc::now().timestamp() as usize, 61 86 scope: Some(scope.to_string()), 62 87 lxm: None, 63 - jti: uuid::Uuid::new_v4().to_string(), 88 + jti: jti.clone(), 64 89 }; 65 90 66 - sign_claims(claims, &signing_key) 91 + let token = sign_claims_with_type(claims, &signing_key, typ)?; 92 + Ok(TokenWithMetadata { 93 + token, 94 + jti, 95 + expires_at, 96 + }) 67 97 } 68 98 69 99 fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> { 100 + sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE) 101 + } 102 + 103 + fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> { 70 104 let header = Header { 71 105 alg: "ES256K".to_string(), 72 - typ: "JWT".to_string(), 106 + typ: typ.to_string(), 73 107 }; 74 108 75 109 let header_json = serde_json::to_string(&header)?;
+67 -1
src/auth/verify.rs
··· 1 - use super::{Claims, TokenData, UnsafeClaims}; 1 + use super::{Claims, Header, TokenData, UnsafeClaims}; 2 + use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED}; 2 3 use anyhow::{Context, Result, anyhow}; 3 4 use base64::Engine as _; 4 5 use base64::engine::general_purpose::URL_SAFE_NO_PAD; ··· 21 22 Ok(claims.sub.unwrap_or(claims.iss)) 22 23 } 23 24 25 + pub fn get_jti_from_token(token: &str) -> Result<String, String> { 26 + let parts: Vec<&str> = token.split('.').collect(); 27 + if parts.len() != 3 { 28 + return Err("Invalid token format".to_string()); 29 + } 30 + 31 + let payload_bytes = URL_SAFE_NO_PAD 32 + .decode(parts[1]) 33 + .map_err(|e| format!("Base64 decode failed: {}", e))?; 34 + 35 + let claims: serde_json::Value = 36 + serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; 37 + 38 + claims.get("jti") 39 + .and_then(|j| j.as_str()) 40 + .map(|s| s.to_string()) 41 + .ok_or_else(|| "No jti claim in token".to_string()) 42 + } 43 + 24 44 pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> { 45 + verify_token_internal(token, key_bytes, None, None) 46 + } 47 + 48 + pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> { 49 + verify_token_internal( 50 + token, 51 + key_bytes, 52 + Some(TOKEN_TYPE_ACCESS), 53 + Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]), 54 + ) 55 + } 56 + 57 + pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> { 58 + verify_token_internal( 59 + token, 60 + key_bytes, 61 + Some(TOKEN_TYPE_REFRESH), 62 + Some(&[SCOPE_REFRESH]), 63 + ) 64 + } 65 + 66 + fn verify_token_internal( 67 + token: &str, 68 + key_bytes: &[u8], 69 + expected_typ: Option<&str>, 70 + allowed_scopes: Option<&[&str]>, 71 + ) -> Result<TokenData<Claims>> { 25 72 let parts: Vec<&str> = token.split('.').collect(); 26 73 if parts.len() != 3 { 27 74 return Err(anyhow!("Invalid token format")); ··· 31 78 let claims_b64 = parts[1]; 32 79 let signature_b64 = parts[2]; 33 80 81 + let header_bytes = URL_SAFE_NO_PAD 82 + .decode(header_b64) 83 + .context("Base64 decode of header failed")?; 84 + let header: Header = 85 + serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?; 86 + 87 + if let Some(expected) = expected_typ { 88 + if header.typ != expected { 89 + return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ)); 90 + } 91 + } 92 + 34 93 let signature_bytes = URL_SAFE_NO_PAD 35 94 .decode(signature_b64) 36 95 .context("Base64 decode of signature failed")?; ··· 54 113 let now = Utc::now().timestamp() as usize; 55 114 if claims.exp < now { 56 115 return Err(anyhow!("Token expired")); 116 + } 117 + 118 + if let Some(scopes) = allowed_scopes { 119 + let token_scope = claims.scope.as_deref().unwrap_or(""); 120 + if !scopes.contains(&token_scope) { 121 + return Err(anyhow!("Invalid token scope: {}", token_scope)); 122 + } 57 123 } 58 124 59 125 Ok(TokenData { claims })
+170
src/config.rs
··· 1 + use aes_gcm::{ 2 + Aes256Gcm, KeyInit, Nonce, 3 + aead::Aead, 4 + }; 5 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use hkdf::Hkdf; 7 + use p256::ecdsa::SigningKey; 8 + use sha2::{Digest, Sha256}; 9 + use std::sync::OnceLock; 10 + 11 + static CONFIG: OnceLock<AuthConfig> = OnceLock::new(); 12 + 13 + pub const ENCRYPTION_VERSION: i32 = 1; 14 + 15 + pub struct AuthConfig { 16 + jwt_secret: String, 17 + dpop_secret: String, 18 + #[allow(dead_code)] 19 + signing_key: SigningKey, 20 + pub signing_key_id: String, 21 + pub signing_key_x: String, 22 + pub signing_key_y: String, 23 + key_encryption_key: [u8; 32], 24 + } 25 + 26 + impl AuthConfig { 27 + pub fn init() -> &'static Self { 28 + CONFIG.get_or_init(|| { 29 + let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| { 30 + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { 31 + "test-jwt-secret-not-for-production".to_string() 32 + } else { 33 + panic!( 34 + "JWT_SECRET environment variable must be set in production. \ 35 + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 36 + ); 37 + } 38 + }); 39 + 40 + let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| { 41 + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { 42 + "test-dpop-secret-not-for-production".to_string() 43 + } else { 44 + panic!( 45 + "DPOP_SECRET environment variable must be set in production. \ 46 + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 47 + ); 48 + } 49 + }); 50 + 51 + if jwt_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { 52 + panic!("JWT_SECRET must be at least 32 characters"); 53 + } 54 + if dpop_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { 55 + panic!("DPOP_SECRET must be at least 32 characters"); 56 + } 57 + 58 + let mut hasher = Sha256::new(); 59 + hasher.update(b"oauth-signing-key-derivation:"); 60 + hasher.update(jwt_secret.as_bytes()); 61 + let seed = hasher.finalize(); 62 + 63 + let signing_key = SigningKey::from_slice(&seed) 64 + .expect("Failed to create signing key from seed"); 65 + 66 + let verifying_key = signing_key.verifying_key(); 67 + let point = verifying_key.to_encoded_point(false); 68 + 69 + let signing_key_x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 70 + let signing_key_y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 71 + 72 + let mut kid_hasher = Sha256::new(); 73 + kid_hasher.update(signing_key_x.as_bytes()); 74 + kid_hasher.update(signing_key_y.as_bytes()); 75 + let kid_hash = kid_hasher.finalize(); 76 + let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]); 77 + 78 + let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { 79 + if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() { 80 + "test-master-key-not-for-production".to_string() 81 + } else { 82 + panic!( 83 + "MASTER_KEY environment variable must be set in production. \ 84 + Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing." 85 + ); 86 + } 87 + }); 88 + 89 + if master_key.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() { 90 + panic!("MASTER_KEY must be at least 32 characters"); 91 + } 92 + 93 + let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes()); 94 + let mut key_encryption_key = [0u8; 32]; 95 + hk.expand(b"bspds-user-key-encryption", &mut key_encryption_key) 96 + .expect("HKDF expansion failed"); 97 + 98 + AuthConfig { 99 + jwt_secret, 100 + dpop_secret, 101 + signing_key, 102 + signing_key_id, 103 + signing_key_x, 104 + signing_key_y, 105 + key_encryption_key, 106 + } 107 + }) 108 + } 109 + 110 + pub fn get() -> &'static Self { 111 + CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first") 112 + } 113 + 114 + pub fn jwt_secret(&self) -> &str { 115 + &self.jwt_secret 116 + } 117 + 118 + pub fn dpop_secret(&self) -> &str { 119 + &self.dpop_secret 120 + } 121 + 122 + pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> { 123 + use rand::RngCore; 124 + 125 + let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) 126 + .map_err(|e| format!("Failed to create cipher: {}", e))?; 127 + 128 + let mut nonce_bytes = [0u8; 12]; 129 + rand::thread_rng().fill_bytes(&mut nonce_bytes); 130 + let nonce = Nonce::from_slice(&nonce_bytes); 131 + 132 + let ciphertext = cipher 133 + .encrypt(nonce, plaintext) 134 + .map_err(|e| format!("Encryption failed: {}", e))?; 135 + 136 + let mut result = Vec::with_capacity(12 + ciphertext.len()); 137 + result.extend_from_slice(&nonce_bytes); 138 + result.extend_from_slice(&ciphertext); 139 + 140 + Ok(result) 141 + } 142 + 143 + pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> { 144 + if encrypted.len() < 12 { 145 + return Err("Encrypted data too short".to_string()); 146 + } 147 + 148 + let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) 149 + .map_err(|e| format!("Failed to create cipher: {}", e))?; 150 + 151 + let nonce = Nonce::from_slice(&encrypted[..12]); 152 + let ciphertext = &encrypted[12..]; 153 + 154 + cipher 155 + .decrypt(nonce, ciphertext) 156 + .map_err(|e| format!("Decryption failed: {}", e)) 157 + } 158 + } 159 + 160 + pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, String> { 161 + AuthConfig::get().encrypt_user_key(plaintext) 162 + } 163 + 164 + pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, String> { 165 + match version.unwrap_or(0) { 166 + 0 => Ok(encrypted.to_vec()), 167 + 1 => AuthConfig::get().decrypt_user_key(encrypted), 168 + v => Err(format!("Unknown encryption version: {}", v)), 169 + } 170 + }
+21
src/lib.rs
··· 1 1 pub mod api; 2 2 pub mod auth; 3 + pub mod config; 3 4 pub mod notifications; 5 + pub mod oauth; 4 6 pub mod repo; 5 7 pub mod state; 6 8 pub mod storage; ··· 267 269 ) 268 270 .route("/.well-known/did.json", get(api::identity::well_known_did)) 269 271 .route("/u/{handle}/did.json", get(api::identity::user_did_doc)) 272 + // OAuth 2.1 endpoints 273 + .route( 274 + "/.well-known/oauth-protected-resource", 275 + get(oauth::endpoints::oauth_protected_resource), 276 + ) 277 + .route( 278 + "/.well-known/oauth-authorization-server", 279 + get(oauth::endpoints::oauth_authorization_server), 280 + ) 281 + .route("/oauth/jwks", get(oauth::endpoints::oauth_jwks)) 282 + .route( 283 + "/oauth/par", 284 + post(oauth::endpoints::pushed_authorization_request), 285 + ) 286 + .route("/oauth/authorize", get(oauth::endpoints::authorize_get)) 287 + .route("/oauth/authorize", post(oauth::endpoints::authorize_post)) 288 + .route("/oauth/token", post(oauth::endpoints::token_endpoint)) 289 + .route("/oauth/revoke", post(oauth::endpoints::revoke_token)) 290 + .route("/oauth/introspect", post(oauth::endpoints::introspect_token)) 270 291 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 271 292 .with_state(state) 272 293 }
+365
src/oauth/client.rs
··· 1 + use reqwest::Client; 2 + use serde::{Deserialize, Serialize}; 3 + use std::collections::HashMap; 4 + use std::sync::Arc; 5 + use tokio::sync::RwLock; 6 + 7 + use super::OAuthError; 8 + 9 + #[derive(Debug, Clone, Serialize, Deserialize)] 10 + pub struct ClientMetadata { 11 + pub client_id: String, 12 + #[serde(skip_serializing_if = "Option::is_none")] 13 + pub client_name: Option<String>, 14 + #[serde(skip_serializing_if = "Option::is_none")] 15 + pub client_uri: Option<String>, 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub logo_uri: Option<String>, 18 + pub redirect_uris: Vec<String>, 19 + #[serde(default)] 20 + pub grant_types: Vec<String>, 21 + #[serde(default)] 22 + pub response_types: Vec<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub scope: Option<String>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub token_endpoint_auth_method: Option<String>, 27 + #[serde(skip_serializing_if = "Option::is_none")] 28 + pub dpop_bound_access_tokens: Option<bool>, 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub jwks: Option<serde_json::Value>, 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub jwks_uri: Option<String>, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub application_type: Option<String>, 35 + } 36 + 37 + impl Default for ClientMetadata { 38 + fn default() -> Self { 39 + Self { 40 + client_id: String::new(), 41 + client_name: None, 42 + client_uri: None, 43 + logo_uri: None, 44 + redirect_uris: Vec::new(), 45 + grant_types: vec!["authorization_code".to_string()], 46 + response_types: vec!["code".to_string()], 47 + scope: None, 48 + token_endpoint_auth_method: Some("none".to_string()), 49 + dpop_bound_access_tokens: None, 50 + jwks: None, 51 + jwks_uri: None, 52 + application_type: None, 53 + } 54 + } 55 + } 56 + 57 + #[derive(Clone)] 58 + pub struct ClientMetadataCache { 59 + cache: Arc<RwLock<HashMap<String, CachedMetadata>>>, 60 + http_client: Client, 61 + cache_ttl_secs: u64, 62 + } 63 + 64 + struct CachedMetadata { 65 + metadata: ClientMetadata, 66 + cached_at: std::time::Instant, 67 + } 68 + 69 + impl ClientMetadataCache { 70 + pub fn new(cache_ttl_secs: u64) -> Self { 71 + Self { 72 + cache: Arc::new(RwLock::new(HashMap::new())), 73 + http_client: Client::new(), 74 + cache_ttl_secs, 75 + } 76 + } 77 + 78 + pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> { 79 + { 80 + let cache = self.cache.read().await; 81 + if let Some(cached) = cache.get(client_id) { 82 + if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs { 83 + return Ok(cached.metadata.clone()); 84 + } 85 + } 86 + } 87 + 88 + let metadata = self.fetch_metadata(client_id).await?; 89 + 90 + { 91 + let mut cache = self.cache.write().await; 92 + cache.insert( 93 + client_id.to_string(), 94 + CachedMetadata { 95 + metadata: metadata.clone(), 96 + cached_at: std::time::Instant::now(), 97 + }, 98 + ); 99 + } 100 + 101 + Ok(metadata) 102 + } 103 + 104 + async fn fetch_metadata(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> { 105 + if !client_id.starts_with("http://") && !client_id.starts_with("https://") { 106 + return Err(OAuthError::InvalidClient( 107 + "client_id must be a URL".to_string(), 108 + )); 109 + } 110 + 111 + if client_id.starts_with("http://") 112 + && !client_id.contains("localhost") 113 + && !client_id.contains("127.0.0.1") 114 + { 115 + return Err(OAuthError::InvalidClient( 116 + "Non-localhost client_id must use https".to_string(), 117 + )); 118 + } 119 + 120 + let response = self 121 + .http_client 122 + .get(client_id) 123 + .header("Accept", "application/json") 124 + .send() 125 + .await 126 + .map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?; 127 + 128 + if !response.status().is_success() { 129 + return Err(OAuthError::InvalidClient(format!( 130 + "Failed to fetch client metadata: HTTP {}", 131 + response.status() 132 + ))); 133 + } 134 + 135 + let mut metadata: ClientMetadata = response 136 + .json() 137 + .await 138 + .map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?; 139 + 140 + if metadata.client_id.is_empty() { 141 + metadata.client_id = client_id.to_string(); 142 + } else if metadata.client_id != client_id { 143 + return Err(OAuthError::InvalidClient( 144 + "client_id in metadata does not match request".to_string(), 145 + )); 146 + } 147 + 148 + self.validate_metadata(&metadata)?; 149 + 150 + Ok(metadata) 151 + } 152 + 153 + fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> { 154 + if metadata.redirect_uris.is_empty() { 155 + return Err(OAuthError::InvalidClient( 156 + "redirect_uris is required".to_string(), 157 + )); 158 + } 159 + 160 + for uri in &metadata.redirect_uris { 161 + self.validate_redirect_uri_format(uri)?; 162 + } 163 + 164 + if !metadata.grant_types.is_empty() 165 + && !metadata.grant_types.contains(&"authorization_code".to_string()) 166 + { 167 + return Err(OAuthError::InvalidClient( 168 + "authorization_code grant type is required".to_string(), 169 + )); 170 + } 171 + 172 + if !metadata.response_types.is_empty() 173 + && !metadata.response_types.contains(&"code".to_string()) 174 + { 175 + return Err(OAuthError::InvalidClient( 176 + "code response type is required".to_string(), 177 + )); 178 + } 179 + 180 + Ok(()) 181 + } 182 + 183 + pub fn validate_redirect_uri( 184 + &self, 185 + metadata: &ClientMetadata, 186 + redirect_uri: &str, 187 + ) -> Result<(), OAuthError> { 188 + if !metadata.redirect_uris.contains(&redirect_uri.to_string()) { 189 + return Err(OAuthError::InvalidRequest( 190 + "redirect_uri not registered for client".to_string(), 191 + )); 192 + } 193 + Ok(()) 194 + } 195 + 196 + fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> { 197 + if uri.contains('#') { 198 + return Err(OAuthError::InvalidClient( 199 + "redirect_uri must not contain a fragment".to_string(), 200 + )); 201 + } 202 + 203 + let parsed = reqwest::Url::parse(uri).map_err(|_| { 204 + OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)) 205 + })?; 206 + 207 + let scheme = parsed.scheme(); 208 + 209 + if scheme == "http" { 210 + let host = parsed.host_str().unwrap_or(""); 211 + if host != "localhost" && host != "127.0.0.1" && host != "[::1]" { 212 + return Err(OAuthError::InvalidClient( 213 + "http redirect_uri only allowed for localhost".to_string(), 214 + )); 215 + } 216 + } else if scheme == "https" { 217 + } else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') { 218 + if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { 219 + return Err(OAuthError::InvalidClient(format!( 220 + "Invalid redirect_uri scheme: {}", 221 + scheme 222 + ))); 223 + } 224 + } else { 225 + return Err(OAuthError::InvalidClient(format!( 226 + "Invalid redirect_uri scheme: {}", 227 + scheme 228 + ))); 229 + } 230 + 231 + Ok(()) 232 + } 233 + } 234 + 235 + impl ClientMetadata { 236 + pub fn requires_dpop(&self) -> bool { 237 + self.dpop_bound_access_tokens.unwrap_or(false) 238 + } 239 + 240 + pub fn auth_method(&self) -> &str { 241 + self.token_endpoint_auth_method 242 + .as_deref() 243 + .unwrap_or("none") 244 + } 245 + } 246 + 247 + pub fn verify_client_auth( 248 + metadata: &ClientMetadata, 249 + client_auth: &super::ClientAuth, 250 + ) -> Result<(), OAuthError> { 251 + let expected_method = metadata.auth_method(); 252 + 253 + match (expected_method, client_auth) { 254 + ("none", super::ClientAuth::None) => Ok(()), 255 + 256 + ("none", _) => Err(OAuthError::InvalidClient( 257 + "Client is configured for no authentication, but credentials were provided".to_string(), 258 + )), 259 + 260 + ("private_key_jwt", super::ClientAuth::PrivateKeyJwt { client_assertion }) => { 261 + verify_private_key_jwt(metadata, client_assertion) 262 + } 263 + 264 + ("private_key_jwt", _) => Err(OAuthError::InvalidClient( 265 + "Client requires private_key_jwt authentication".to_string(), 266 + )), 267 + 268 + ("client_secret_post", super::ClientAuth::SecretPost { .. }) => { 269 + Err(OAuthError::InvalidClient( 270 + "client_secret_post is not supported for ATProto OAuth".to_string(), 271 + )) 272 + } 273 + 274 + ("client_secret_basic", super::ClientAuth::SecretBasic { .. }) => { 275 + Err(OAuthError::InvalidClient( 276 + "client_secret_basic is not supported for ATProto OAuth".to_string(), 277 + )) 278 + } 279 + 280 + (method, _) => Err(OAuthError::InvalidClient(format!( 281 + "Unsupported or mismatched authentication method: {}", 282 + method 283 + ))), 284 + } 285 + } 286 + 287 + fn verify_private_key_jwt( 288 + metadata: &ClientMetadata, 289 + client_assertion: &str, 290 + ) -> Result<(), OAuthError> { 291 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 292 + 293 + let parts: Vec<&str> = client_assertion.split('.').collect(); 294 + if parts.len() != 3 { 295 + return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string())); 296 + } 297 + 298 + let header_bytes = URL_SAFE_NO_PAD 299 + .decode(parts[0]) 300 + .map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?; 301 + let header: serde_json::Value = serde_json::from_slice(&header_bytes) 302 + .map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?; 303 + 304 + let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| { 305 + OAuthError::InvalidClient("Missing alg in client_assertion".to_string()) 306 + })?; 307 + 308 + if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") { 309 + return Err(OAuthError::InvalidClient(format!( 310 + "Unsupported client_assertion algorithm: {}", 311 + alg 312 + ))); 313 + } 314 + 315 + let payload_bytes = URL_SAFE_NO_PAD 316 + .decode(parts[1]) 317 + .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload encoding".to_string()))?; 318 + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) 319 + .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?; 320 + 321 + let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| { 322 + OAuthError::InvalidClient("Missing iss in client_assertion".to_string()) 323 + })?; 324 + if iss != metadata.client_id { 325 + return Err(OAuthError::InvalidClient( 326 + "client_assertion iss does not match client_id".to_string(), 327 + )); 328 + } 329 + 330 + let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| { 331 + OAuthError::InvalidClient("Missing sub in client_assertion".to_string()) 332 + })?; 333 + if sub != metadata.client_id { 334 + return Err(OAuthError::InvalidClient( 335 + "client_assertion sub does not match client_id".to_string(), 336 + )); 337 + } 338 + 339 + let exp = payload.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| { 340 + OAuthError::InvalidClient("Missing exp in client_assertion".to_string()) 341 + })?; 342 + let now = chrono::Utc::now().timestamp(); 343 + if exp < now { 344 + return Err(OAuthError::InvalidClient("client_assertion has expired".to_string())); 345 + } 346 + 347 + let iat = payload.get("iat").and_then(|i| i.as_i64()); 348 + if let Some(iat) = iat { 349 + if iat > now + 60 { 350 + return Err(OAuthError::InvalidClient( 351 + "client_assertion iat is in the future".to_string(), 352 + )); 353 + } 354 + } 355 + 356 + if metadata.jwks.is_none() && metadata.jwks_uri.is_none() { 357 + return Err(OAuthError::InvalidClient( 358 + "Client using private_key_jwt must have jwks or jwks_uri".to_string(), 359 + )); 360 + } 361 + 362 + Err(OAuthError::InvalidClient( 363 + "private_key_jwt signature verification not yet implemented - use 'none' auth method".to_string(), 364 + )) 365 + }
+641
src/oauth/db.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{de::DeserializeOwned, Serialize}; 3 + use sqlx::PgPool; 4 + 5 + use super::{ 6 + AuthorizationRequestParameters, ClientAuth, DeviceData, OAuthError, RequestData, TokenData, 7 + AuthorizedClientData, 8 + }; 9 + 10 + fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> { 11 + serde_json::to_value(value).map_err(|e| { 12 + tracing::error!("JSON serialization error: {}", e); 13 + OAuthError::ServerError("Internal serialization error".to_string()) 14 + }) 15 + } 16 + 17 + fn from_json<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, OAuthError> { 18 + serde_json::from_value(value).map_err(|e| { 19 + tracing::error!("JSON deserialization error: {}", e); 20 + OAuthError::ServerError("Internal data corruption".to_string()) 21 + }) 22 + } 23 + 24 + pub async fn create_device( 25 + pool: &PgPool, 26 + device_id: &str, 27 + data: &DeviceData, 28 + ) -> Result<(), OAuthError> { 29 + sqlx::query!( 30 + r#" 31 + INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at) 32 + VALUES ($1, $2, $3, $4, $5) 33 + "#, 34 + device_id, 35 + data.session_id, 36 + data.user_agent, 37 + data.ip_address, 38 + data.last_seen_at, 39 + ) 40 + .execute(pool) 41 + .await?; 42 + 43 + Ok(()) 44 + } 45 + 46 + pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceData>, OAuthError> { 47 + let row = sqlx::query!( 48 + r#" 49 + SELECT session_id, user_agent, ip_address, last_seen_at 50 + FROM oauth_device 51 + WHERE id = $1 52 + "#, 53 + device_id 54 + ) 55 + .fetch_optional(pool) 56 + .await?; 57 + 58 + Ok(row.map(|r| DeviceData { 59 + session_id: r.session_id, 60 + user_agent: r.user_agent, 61 + ip_address: r.ip_address, 62 + last_seen_at: r.last_seen_at, 63 + })) 64 + } 65 + 66 + pub async fn update_device_last_seen( 67 + pool: &PgPool, 68 + device_id: &str, 69 + ) -> Result<(), OAuthError> { 70 + sqlx::query!( 71 + r#" 72 + UPDATE oauth_device 73 + SET last_seen_at = NOW() 74 + WHERE id = $1 75 + "#, 76 + device_id 77 + ) 78 + .execute(pool) 79 + .await?; 80 + 81 + Ok(()) 82 + } 83 + 84 + pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> { 85 + sqlx::query!( 86 + r#" 87 + DELETE FROM oauth_device WHERE id = $1 88 + "#, 89 + device_id 90 + ) 91 + .execute(pool) 92 + .await?; 93 + 94 + Ok(()) 95 + } 96 + 97 + pub async fn create_authorization_request( 98 + pool: &PgPool, 99 + request_id: &str, 100 + data: &RequestData, 101 + ) -> Result<(), OAuthError> { 102 + let client_auth_json = match &data.client_auth { 103 + Some(ca) => Some(to_json(ca)?), 104 + None => None, 105 + }; 106 + let parameters_json = to_json(&data.parameters)?; 107 + 108 + sqlx::query!( 109 + r#" 110 + INSERT INTO oauth_authorization_request 111 + (id, did, device_id, client_id, client_auth, parameters, expires_at, code) 112 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 113 + "#, 114 + request_id, 115 + data.did, 116 + data.device_id, 117 + data.client_id, 118 + client_auth_json, 119 + parameters_json, 120 + data.expires_at, 121 + data.code, 122 + ) 123 + .execute(pool) 124 + .await?; 125 + 126 + Ok(()) 127 + } 128 + 129 + pub async fn get_authorization_request( 130 + pool: &PgPool, 131 + request_id: &str, 132 + ) -> Result<Option<RequestData>, OAuthError> { 133 + let row = sqlx::query!( 134 + r#" 135 + SELECT did, device_id, client_id, client_auth, parameters, expires_at, code 136 + FROM oauth_authorization_request 137 + WHERE id = $1 138 + "#, 139 + request_id 140 + ) 141 + .fetch_optional(pool) 142 + .await?; 143 + 144 + match row { 145 + Some(r) => { 146 + let client_auth: Option<ClientAuth> = match r.client_auth { 147 + Some(v) => Some(from_json(v)?), 148 + None => None, 149 + }; 150 + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; 151 + 152 + Ok(Some(RequestData { 153 + client_id: r.client_id, 154 + client_auth, 155 + parameters, 156 + expires_at: r.expires_at, 157 + did: r.did, 158 + device_id: r.device_id, 159 + code: r.code, 160 + })) 161 + } 162 + None => Ok(None), 163 + } 164 + } 165 + 166 + pub async fn update_authorization_request( 167 + pool: &PgPool, 168 + request_id: &str, 169 + did: &str, 170 + device_id: Option<&str>, 171 + code: &str, 172 + ) -> Result<(), OAuthError> { 173 + sqlx::query!( 174 + r#" 175 + UPDATE oauth_authorization_request 176 + SET did = $2, device_id = $3, code = $4 177 + WHERE id = $1 178 + "#, 179 + request_id, 180 + did, 181 + device_id, 182 + code 183 + ) 184 + .execute(pool) 185 + .await?; 186 + 187 + Ok(()) 188 + } 189 + 190 + pub async fn consume_authorization_request_by_code( 191 + pool: &PgPool, 192 + code: &str, 193 + ) -> Result<Option<RequestData>, OAuthError> { 194 + let row = sqlx::query!( 195 + r#" 196 + DELETE FROM oauth_authorization_request 197 + WHERE code = $1 198 + RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code 199 + "#, 200 + code 201 + ) 202 + .fetch_optional(pool) 203 + .await?; 204 + 205 + match row { 206 + Some(r) => { 207 + let client_auth: Option<ClientAuth> = match r.client_auth { 208 + Some(v) => Some(from_json(v)?), 209 + None => None, 210 + }; 211 + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; 212 + 213 + Ok(Some(RequestData { 214 + client_id: r.client_id, 215 + client_auth, 216 + parameters, 217 + expires_at: r.expires_at, 218 + did: r.did, 219 + device_id: r.device_id, 220 + code: r.code, 221 + })) 222 + } 223 + None => Ok(None), 224 + } 225 + } 226 + 227 + pub async fn delete_authorization_request( 228 + pool: &PgPool, 229 + request_id: &str, 230 + ) -> Result<(), OAuthError> { 231 + sqlx::query!( 232 + r#" 233 + DELETE FROM oauth_authorization_request WHERE id = $1 234 + "#, 235 + request_id 236 + ) 237 + .execute(pool) 238 + .await?; 239 + 240 + Ok(()) 241 + } 242 + 243 + pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result<u64, OAuthError> { 244 + let result = sqlx::query!( 245 + r#" 246 + DELETE FROM oauth_authorization_request 247 + WHERE expires_at < NOW() 248 + "# 249 + ) 250 + .execute(pool) 251 + .await?; 252 + 253 + Ok(result.rows_affected()) 254 + } 255 + 256 + pub async fn create_token( 257 + pool: &PgPool, 258 + data: &TokenData, 259 + ) -> Result<i32, OAuthError> { 260 + let client_auth_json = to_json(&data.client_auth)?; 261 + let parameters_json = to_json(&data.parameters)?; 262 + 263 + let row = sqlx::query!( 264 + r#" 265 + INSERT INTO oauth_token 266 + (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 267 + device_id, parameters, details, code, current_refresh_token, scope) 268 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 269 + RETURNING id 270 + "#, 271 + data.did, 272 + data.token_id, 273 + data.created_at, 274 + data.updated_at, 275 + data.expires_at, 276 + data.client_id, 277 + client_auth_json, 278 + data.device_id, 279 + parameters_json, 280 + data.details, 281 + data.code, 282 + data.current_refresh_token, 283 + data.scope, 284 + ) 285 + .fetch_one(pool) 286 + .await?; 287 + 288 + Ok(row.id) 289 + } 290 + 291 + pub async fn get_token_by_id( 292 + pool: &PgPool, 293 + token_id: &str, 294 + ) -> Result<Option<TokenData>, OAuthError> { 295 + let row = sqlx::query!( 296 + r#" 297 + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 298 + device_id, parameters, details, code, current_refresh_token, scope 299 + FROM oauth_token 300 + WHERE token_id = $1 301 + "#, 302 + token_id 303 + ) 304 + .fetch_optional(pool) 305 + .await?; 306 + 307 + match row { 308 + Some(r) => Ok(Some(TokenData { 309 + did: r.did, 310 + token_id: r.token_id, 311 + created_at: r.created_at, 312 + updated_at: r.updated_at, 313 + expires_at: r.expires_at, 314 + client_id: r.client_id, 315 + client_auth: from_json(r.client_auth)?, 316 + device_id: r.device_id, 317 + parameters: from_json(r.parameters)?, 318 + details: r.details, 319 + code: r.code, 320 + current_refresh_token: r.current_refresh_token, 321 + scope: r.scope, 322 + })), 323 + None => Ok(None), 324 + } 325 + } 326 + 327 + pub async fn get_token_by_refresh_token( 328 + pool: &PgPool, 329 + refresh_token: &str, 330 + ) -> Result<Option<(i32, TokenData)>, OAuthError> { 331 + let row = sqlx::query!( 332 + r#" 333 + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 334 + device_id, parameters, details, code, current_refresh_token, scope 335 + FROM oauth_token 336 + WHERE current_refresh_token = $1 337 + "#, 338 + refresh_token 339 + ) 340 + .fetch_optional(pool) 341 + .await?; 342 + 343 + match row { 344 + Some(r) => Ok(Some(( 345 + r.id, 346 + TokenData { 347 + did: r.did, 348 + token_id: r.token_id, 349 + created_at: r.created_at, 350 + updated_at: r.updated_at, 351 + expires_at: r.expires_at, 352 + client_id: r.client_id, 353 + client_auth: from_json(r.client_auth)?, 354 + device_id: r.device_id, 355 + parameters: from_json(r.parameters)?, 356 + details: r.details, 357 + code: r.code, 358 + current_refresh_token: r.current_refresh_token, 359 + scope: r.scope, 360 + }, 361 + ))), 362 + None => Ok(None), 363 + } 364 + } 365 + 366 + pub async fn rotate_token( 367 + pool: &PgPool, 368 + old_db_id: i32, 369 + new_token_id: &str, 370 + new_refresh_token: &str, 371 + new_expires_at: DateTime<Utc>, 372 + ) -> Result<(), OAuthError> { 373 + let mut tx = pool.begin().await?; 374 + 375 + let old_refresh = sqlx::query_scalar!( 376 + r#" 377 + SELECT current_refresh_token FROM oauth_token WHERE id = $1 378 + "#, 379 + old_db_id 380 + ) 381 + .fetch_one(&mut *tx) 382 + .await?; 383 + 384 + if let Some(old_rt) = old_refresh { 385 + sqlx::query!( 386 + r#" 387 + INSERT INTO oauth_used_refresh_token (refresh_token, token_id) 388 + VALUES ($1, $2) 389 + "#, 390 + old_rt, 391 + old_db_id 392 + ) 393 + .execute(&mut *tx) 394 + .await?; 395 + } 396 + 397 + sqlx::query!( 398 + r#" 399 + UPDATE oauth_token 400 + SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() 401 + WHERE id = $1 402 + "#, 403 + old_db_id, 404 + new_token_id, 405 + new_refresh_token, 406 + new_expires_at 407 + ) 408 + .execute(&mut *tx) 409 + .await?; 410 + 411 + tx.commit().await?; 412 + Ok(()) 413 + } 414 + 415 + pub async fn check_refresh_token_used( 416 + pool: &PgPool, 417 + refresh_token: &str, 418 + ) -> Result<Option<i32>, OAuthError> { 419 + let row = sqlx::query_scalar!( 420 + r#" 421 + SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 422 + "#, 423 + refresh_token 424 + ) 425 + .fetch_optional(pool) 426 + .await?; 427 + 428 + Ok(row) 429 + } 430 + 431 + pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> { 432 + sqlx::query!( 433 + r#" 434 + DELETE FROM oauth_token WHERE token_id = $1 435 + "#, 436 + token_id 437 + ) 438 + .execute(pool) 439 + .await?; 440 + 441 + Ok(()) 442 + } 443 + 444 + pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> { 445 + sqlx::query!( 446 + r#" 447 + DELETE FROM oauth_token WHERE id = $1 448 + "#, 449 + db_id 450 + ) 451 + .execute(pool) 452 + .await?; 453 + 454 + Ok(()) 455 + } 456 + 457 + pub async fn upsert_account_device( 458 + pool: &PgPool, 459 + did: &str, 460 + device_id: &str, 461 + ) -> Result<(), OAuthError> { 462 + sqlx::query!( 463 + r#" 464 + INSERT INTO oauth_account_device (did, device_id, created_at, updated_at) 465 + VALUES ($1, $2, NOW(), NOW()) 466 + ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW() 467 + "#, 468 + did, 469 + device_id 470 + ) 471 + .execute(pool) 472 + .await?; 473 + 474 + Ok(()) 475 + } 476 + 477 + pub async fn upsert_authorized_client( 478 + pool: &PgPool, 479 + did: &str, 480 + client_id: &str, 481 + data: &AuthorizedClientData, 482 + ) -> Result<(), OAuthError> { 483 + let data_json = to_json(data)?; 484 + 485 + sqlx::query!( 486 + r#" 487 + INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data) 488 + VALUES ($1, $2, NOW(), NOW(), $3) 489 + ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3 490 + "#, 491 + did, 492 + client_id, 493 + data_json 494 + ) 495 + .execute(pool) 496 + .await?; 497 + 498 + Ok(()) 499 + } 500 + 501 + pub async fn get_authorized_client( 502 + pool: &PgPool, 503 + did: &str, 504 + client_id: &str, 505 + ) -> Result<Option<AuthorizedClientData>, OAuthError> { 506 + let row = sqlx::query_scalar!( 507 + r#" 508 + SELECT data FROM oauth_authorized_client 509 + WHERE did = $1 AND client_id = $2 510 + "#, 511 + did, 512 + client_id 513 + ) 514 + .fetch_optional(pool) 515 + .await?; 516 + 517 + match row { 518 + Some(v) => Ok(Some(from_json(v)?)), 519 + None => Ok(None), 520 + } 521 + } 522 + 523 + pub async fn list_tokens_for_user( 524 + pool: &PgPool, 525 + did: &str, 526 + ) -> Result<Vec<TokenData>, OAuthError> { 527 + let rows = sqlx::query!( 528 + r#" 529 + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 530 + device_id, parameters, details, code, current_refresh_token, scope 531 + FROM oauth_token 532 + WHERE did = $1 533 + "#, 534 + did 535 + ) 536 + .fetch_all(pool) 537 + .await?; 538 + 539 + let mut tokens = Vec::with_capacity(rows.len()); 540 + for r in rows { 541 + tokens.push(TokenData { 542 + did: r.did, 543 + token_id: r.token_id, 544 + created_at: r.created_at, 545 + updated_at: r.updated_at, 546 + expires_at: r.expires_at, 547 + client_id: r.client_id, 548 + client_auth: from_json(r.client_auth)?, 549 + device_id: r.device_id, 550 + parameters: from_json(r.parameters)?, 551 + details: r.details, 552 + code: r.code, 553 + current_refresh_token: r.current_refresh_token, 554 + scope: r.scope, 555 + }); 556 + } 557 + Ok(tokens) 558 + } 559 + 560 + pub async fn check_and_record_dpop_jti( 561 + pool: &PgPool, 562 + jti: &str, 563 + ) -> Result<bool, OAuthError> { 564 + let result = sqlx::query!( 565 + r#" 566 + INSERT INTO oauth_dpop_jti (jti) 567 + VALUES ($1) 568 + ON CONFLICT (jti) DO NOTHING 569 + "#, 570 + jti 571 + ) 572 + .execute(pool) 573 + .await?; 574 + 575 + Ok(result.rows_affected() > 0) 576 + } 577 + 578 + pub async fn cleanup_expired_dpop_jtis( 579 + pool: &PgPool, 580 + max_age_secs: i64, 581 + ) -> Result<u64, OAuthError> { 582 + let result = sqlx::query!( 583 + r#" 584 + DELETE FROM oauth_dpop_jti 585 + WHERE created_at < NOW() - INTERVAL '1 second' * $1 586 + "#, 587 + max_age_secs as f64 588 + ) 589 + .execute(pool) 590 + .await?; 591 + 592 + Ok(result.rows_affected()) 593 + } 594 + 595 + pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> { 596 + let count = sqlx::query_scalar!( 597 + r#" 598 + SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1 599 + "#, 600 + did 601 + ) 602 + .fetch_one(pool) 603 + .await?; 604 + 605 + Ok(count) 606 + } 607 + 608 + pub async fn delete_oldest_tokens_for_user( 609 + pool: &PgPool, 610 + did: &str, 611 + keep_count: i64, 612 + ) -> Result<u64, OAuthError> { 613 + let result = sqlx::query!( 614 + r#" 615 + DELETE FROM oauth_token 616 + WHERE id IN ( 617 + SELECT id FROM oauth_token 618 + WHERE did = $1 619 + ORDER BY updated_at ASC 620 + OFFSET $2 621 + ) 622 + "#, 623 + did, 624 + keep_count 625 + ) 626 + .execute(pool) 627 + .await?; 628 + 629 + Ok(result.rows_affected()) 630 + } 631 + 632 + const MAX_TOKENS_PER_USER: i64 = 100; 633 + 634 + pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> { 635 + let count = count_tokens_for_user(pool, did).await?; 636 + if count > MAX_TOKENS_PER_USER { 637 + let to_keep = MAX_TOKENS_PER_USER - 1; 638 + delete_oldest_tokens_for_user(pool, did, to_keep).await?; 639 + } 640 + Ok(()) 641 + }
+421
src/oauth/dpop.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use chrono::Utc; 4 + use serde::{Deserialize, Serialize}; 5 + use sha2::{Digest, Sha256}; 6 + 7 + use super::OAuthError; 8 + 9 + const DPOP_NONCE_VALIDITY_SECS: i64 = 300; 10 + const DPOP_MAX_AGE_SECS: i64 = 300; 11 + 12 + #[derive(Debug, Clone)] 13 + pub struct DPoPVerifyResult { 14 + pub jkt: String, 15 + pub jti: String, 16 + } 17 + 18 + #[derive(Debug, Clone, Serialize, Deserialize)] 19 + pub struct DPoPProofHeader { 20 + pub typ: String, 21 + pub alg: String, 22 + pub jwk: DPoPJwk, 23 + } 24 + 25 + #[derive(Debug, Clone, Serialize, Deserialize)] 26 + pub struct DPoPJwk { 27 + pub kty: String, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub crv: Option<String>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub x: Option<String>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub y: Option<String>, 34 + } 35 + 36 + #[derive(Debug, Clone, Serialize, Deserialize)] 37 + pub struct DPoPProofPayload { 38 + pub jti: String, 39 + pub htm: String, 40 + pub htu: String, 41 + pub iat: i64, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub ath: Option<String>, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub nonce: Option<String>, 46 + } 47 + 48 + pub struct DPoPVerifier { 49 + secret: Vec<u8>, 50 + } 51 + 52 + impl DPoPVerifier { 53 + pub fn new(secret: &[u8]) -> Self { 54 + Self { 55 + secret: secret.to_vec(), 56 + } 57 + } 58 + 59 + pub fn generate_nonce(&self) -> String { 60 + let timestamp = Utc::now().timestamp(); 61 + let timestamp_bytes = timestamp.to_be_bytes(); 62 + 63 + let mut hasher = Sha256::new(); 64 + hasher.update(&self.secret); 65 + hasher.update(&timestamp_bytes); 66 + let hash = hasher.finalize(); 67 + 68 + let mut nonce_data = Vec::with_capacity(8 + 16); 69 + nonce_data.extend_from_slice(&timestamp_bytes); 70 + nonce_data.extend_from_slice(&hash[..16]); 71 + 72 + URL_SAFE_NO_PAD.encode(&nonce_data) 73 + } 74 + 75 + pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> { 76 + let nonce_bytes = URL_SAFE_NO_PAD 77 + .decode(nonce) 78 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?; 79 + 80 + if nonce_bytes.len() < 24 { 81 + return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string())); 82 + } 83 + 84 + let timestamp_bytes: [u8; 8] = nonce_bytes[..8] 85 + .try_into() 86 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce".to_string()))?; 87 + let timestamp = i64::from_be_bytes(timestamp_bytes); 88 + 89 + let now = Utc::now().timestamp(); 90 + if now - timestamp > DPOP_NONCE_VALIDITY_SECS { 91 + return Err(OAuthError::UseDpopNonce(self.generate_nonce())); 92 + } 93 + 94 + let mut hasher = Sha256::new(); 95 + hasher.update(&self.secret); 96 + hasher.update(&timestamp_bytes); 97 + let expected_hash = hasher.finalize(); 98 + 99 + if nonce_bytes[8..24] != expected_hash[..16] { 100 + return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string())); 101 + } 102 + 103 + Ok(()) 104 + } 105 + 106 + pub fn verify_proof( 107 + &self, 108 + dpop_header: &str, 109 + http_method: &str, 110 + http_uri: &str, 111 + access_token_hash: Option<&str>, 112 + ) -> Result<DPoPVerifyResult, OAuthError> { 113 + let parts: Vec<&str> = dpop_header.split('.').collect(); 114 + if parts.len() != 3 { 115 + return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string())); 116 + } 117 + 118 + let header_json = URL_SAFE_NO_PAD 119 + .decode(parts[0]) 120 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid header encoding".to_string()))?; 121 + let payload_json = URL_SAFE_NO_PAD 122 + .decode(parts[1]) 123 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload encoding".to_string()))?; 124 + 125 + let header: DPoPProofHeader = serde_json::from_slice(&header_json) 126 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid header JSON".to_string()))?; 127 + let payload: DPoPProofPayload = serde_json::from_slice(&payload_json) 128 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?; 129 + 130 + if header.typ != "dpop+jwt" { 131 + return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string())); 132 + } 133 + 134 + if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") { 135 + return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string())); 136 + } 137 + 138 + if payload.htm.to_uppercase() != http_method.to_uppercase() { 139 + return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string())); 140 + } 141 + 142 + let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu); 143 + let request_uri = http_uri.split('?').next().unwrap_or(http_uri); 144 + if proof_uri != request_uri { 145 + return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string())); 146 + } 147 + 148 + let now = Utc::now().timestamp(); 149 + if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS { 150 + return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string())); 151 + } 152 + 153 + if let Some(nonce) = &payload.nonce { 154 + self.validate_nonce(nonce)?; 155 + } 156 + 157 + if let Some(expected_ath) = access_token_hash { 158 + match &payload.ath { 159 + Some(ath) if ath == expected_ath => {} 160 + Some(_) => { 161 + return Err(OAuthError::InvalidDpopProof( 162 + "Access token hash mismatch".to_string(), 163 + )); 164 + } 165 + None => { 166 + return Err(OAuthError::InvalidDpopProof( 167 + "Missing access token hash".to_string(), 168 + )); 169 + } 170 + } 171 + } 172 + 173 + let signature_bytes = URL_SAFE_NO_PAD 174 + .decode(parts[2]) 175 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?; 176 + 177 + let signing_input = format!("{}.{}", parts[0], parts[1]); 178 + verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?; 179 + 180 + let jkt = compute_jwk_thumbprint(&header.jwk)?; 181 + 182 + Ok(DPoPVerifyResult { 183 + jkt, 184 + jti: payload.jti.clone(), 185 + }) 186 + } 187 + } 188 + 189 + fn verify_dpop_signature( 190 + alg: &str, 191 + jwk: &DPoPJwk, 192 + message: &[u8], 193 + signature: &[u8], 194 + ) -> Result<(), OAuthError> { 195 + match alg { 196 + "ES256" => verify_es256(jwk, message, signature), 197 + "ES384" => verify_es384(jwk, message, signature), 198 + "EdDSA" => verify_eddsa(jwk, message, signature), 199 + _ => Err(OAuthError::InvalidDpopProof(format!( 200 + "Unsupported algorithm: {}", 201 + alg 202 + ))), 203 + } 204 + } 205 + 206 + fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { 207 + use p256::ecdsa::signature::Verifier; 208 + use p256::ecdsa::{Signature, VerifyingKey}; 209 + use p256::elliptic_curve::sec1::FromEncodedPoint; 210 + use p256::{AffinePoint, EncodedPoint}; 211 + 212 + let crv = jwk.crv.as_ref().ok_or_else(|| { 213 + OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()) 214 + })?; 215 + if crv != "P-256" { 216 + return Err(OAuthError::InvalidDpopProof(format!( 217 + "Invalid curve for ES256: {}", 218 + crv 219 + ))); 220 + } 221 + 222 + let x_bytes = URL_SAFE_NO_PAD 223 + .decode(jwk.x.as_ref().ok_or_else(|| { 224 + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) 225 + })?) 226 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 227 + 228 + let y_bytes = URL_SAFE_NO_PAD 229 + .decode(jwk.y.as_ref().ok_or_else(|| { 230 + OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) 231 + })?) 232 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 233 + 234 + let point = EncodedPoint::from_affine_coordinates( 235 + x_bytes.as_slice().into(), 236 + y_bytes.as_slice().into(), 237 + false, 238 + ); 239 + 240 + let affine = AffinePoint::from_encoded_point(&point); 241 + if affine.is_none().into() { 242 + return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); 243 + } 244 + 245 + let verifying_key = VerifyingKey::from_affine(affine.unwrap()) 246 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; 247 + 248 + let sig = Signature::from_slice(signature) 249 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?; 250 + 251 + verifying_key 252 + .verify(message, &sig) 253 + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) 254 + } 255 + 256 + fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { 257 + use p384::ecdsa::signature::Verifier; 258 + use p384::ecdsa::{Signature, VerifyingKey}; 259 + use p384::elliptic_curve::sec1::FromEncodedPoint; 260 + use p384::{AffinePoint, EncodedPoint}; 261 + 262 + let crv = jwk.crv.as_ref().ok_or_else(|| { 263 + OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()) 264 + })?; 265 + if crv != "P-384" { 266 + return Err(OAuthError::InvalidDpopProof(format!( 267 + "Invalid curve for ES384: {}", 268 + crv 269 + ))); 270 + } 271 + 272 + let x_bytes = URL_SAFE_NO_PAD 273 + .decode(jwk.x.as_ref().ok_or_else(|| { 274 + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) 275 + })?) 276 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 277 + 278 + let y_bytes = URL_SAFE_NO_PAD 279 + .decode(jwk.y.as_ref().ok_or_else(|| { 280 + OAuthError::InvalidDpopProof("Missing y coordinate".to_string()) 281 + })?) 282 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 283 + 284 + let point = EncodedPoint::from_affine_coordinates( 285 + x_bytes.as_slice().into(), 286 + y_bytes.as_slice().into(), 287 + false, 288 + ); 289 + 290 + let affine = AffinePoint::from_encoded_point(&point); 291 + if affine.is_none().into() { 292 + return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); 293 + } 294 + 295 + let verifying_key = VerifyingKey::from_affine(affine.unwrap()) 296 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; 297 + 298 + let sig = Signature::from_slice(signature) 299 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?; 300 + 301 + verifying_key 302 + .verify(message, &sig) 303 + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) 304 + } 305 + 306 + fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> { 307 + use ed25519_dalek::{Signature, VerifyingKey}; 308 + 309 + let crv = jwk.crv.as_ref().ok_or_else(|| { 310 + OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()) 311 + })?; 312 + if crv != "Ed25519" { 313 + return Err(OAuthError::InvalidDpopProof(format!( 314 + "Invalid curve for EdDSA: {}", 315 + crv 316 + ))); 317 + } 318 + 319 + let x_bytes = URL_SAFE_NO_PAD 320 + .decode(jwk.x.as_ref().ok_or_else(|| { 321 + OAuthError::InvalidDpopProof("Missing x coordinate".to_string()) 322 + })?) 323 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 324 + 325 + let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| { 326 + OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()) 327 + })?; 328 + 329 + let verifying_key = VerifyingKey::from_bytes(&key_bytes) 330 + .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?; 331 + 332 + let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| { 333 + OAuthError::InvalidDpopProof("Invalid Ed25519 signature length".to_string()) 334 + })?; 335 + let sig = Signature::from_bytes(&sig_bytes); 336 + 337 + verifying_key 338 + .verify_strict(message, &sig) 339 + .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string())) 340 + } 341 + 342 + pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> { 343 + let canonical = match jwk.kty.as_str() { 344 + "EC" => { 345 + let crv = jwk 346 + .crv 347 + .as_ref() 348 + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?; 349 + let x = jwk 350 + .x 351 + .as_ref() 352 + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?; 353 + let y = jwk 354 + .y 355 + .as_ref() 356 + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?; 357 + 358 + format!( 359 + r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, 360 + crv, x, y 361 + ) 362 + } 363 + "OKP" => { 364 + let crv = jwk 365 + .crv 366 + .as_ref() 367 + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?; 368 + let x = jwk 369 + .x 370 + .as_ref() 371 + .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?; 372 + 373 + format!(r#"{{"crv":"{}","kty":"OKP","x":"{}"}}"#, crv, x) 374 + } 375 + _ => { 376 + return Err(OAuthError::InvalidDpopProof( 377 + "Unsupported key type".to_string(), 378 + )); 379 + } 380 + }; 381 + 382 + let mut hasher = Sha256::new(); 383 + hasher.update(canonical.as_bytes()); 384 + let hash = hasher.finalize(); 385 + 386 + Ok(URL_SAFE_NO_PAD.encode(&hash)) 387 + } 388 + 389 + pub fn compute_access_token_hash(access_token: &str) -> String { 390 + let mut hasher = Sha256::new(); 391 + hasher.update(access_token.as_bytes()); 392 + let hash = hasher.finalize(); 393 + URL_SAFE_NO_PAD.encode(&hash) 394 + } 395 + 396 + #[cfg(test)] 397 + mod tests { 398 + use super::*; 399 + 400 + #[test] 401 + fn test_nonce_generation_and_validation() { 402 + let secret = b"test-secret-key-32-bytes-long!!!"; 403 + let verifier = DPoPVerifier::new(secret); 404 + 405 + let nonce = verifier.generate_nonce(); 406 + assert!(verifier.validate_nonce(&nonce).is_ok()); 407 + } 408 + 409 + #[test] 410 + fn test_jwk_thumbprint_ec() { 411 + let jwk = DPoPJwk { 412 + kty: "EC".to_string(), 413 + crv: Some("P-256".to_string()), 414 + x: Some("test_x".to_string()), 415 + y: Some("test_y".to_string()), 416 + }; 417 + 418 + let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 419 + assert!(!thumbprint.is_empty()); 420 + } 421 + }
+210
src/oauth/endpoints/authorize.rs
··· 1 + use axum::{ 2 + Form, Json, 3 + extract::{Query, State}, 4 + http::HeaderMap, 5 + response::{IntoResponse, Redirect, Response}, 6 + }; 7 + use chrono::Utc; 8 + use serde::{Deserialize, Serialize}; 9 + use urlencoding::encode as url_encode; 10 + 11 + use crate::state::AppState; 12 + use crate::oauth::{Code, DeviceData, DeviceId, OAuthError, SessionId, db}; 13 + 14 + fn extract_client_ip(headers: &HeaderMap) -> String { 15 + if let Some(forwarded) = headers.get("x-forwarded-for") { 16 + if let Ok(value) = forwarded.to_str() { 17 + if let Some(first_ip) = value.split(',').next() { 18 + return first_ip.trim().to_string(); 19 + } 20 + } 21 + } 22 + 23 + if let Some(real_ip) = headers.get("x-real-ip") { 24 + if let Ok(value) = real_ip.to_str() { 25 + return value.trim().to_string(); 26 + } 27 + } 28 + 29 + "0.0.0.0".to_string() 30 + } 31 + 32 + fn extract_user_agent(headers: &HeaderMap) -> Option<String> { 33 + headers 34 + .get("user-agent") 35 + .and_then(|v| v.to_str().ok()) 36 + .map(|s| s.to_string()) 37 + } 38 + 39 + #[derive(Debug, Deserialize)] 40 + pub struct AuthorizeQuery { 41 + pub request_uri: Option<String>, 42 + pub client_id: Option<String>, 43 + } 44 + 45 + #[derive(Debug, Serialize)] 46 + pub struct AuthorizeResponse { 47 + pub client_id: String, 48 + pub client_name: Option<String>, 49 + pub scope: Option<String>, 50 + pub redirect_uri: String, 51 + pub state: Option<String>, 52 + pub login_hint: Option<String>, 53 + } 54 + 55 + #[derive(Debug, Deserialize)] 56 + pub struct AuthorizeSubmit { 57 + pub request_uri: String, 58 + pub username: String, 59 + pub password: String, 60 + #[serde(default)] 61 + pub remember_device: bool, 62 + } 63 + 64 + pub async fn authorize_get( 65 + State(state): State<AppState>, 66 + Query(query): Query<AuthorizeQuery>, 67 + ) -> Result<Json<AuthorizeResponse>, OAuthError> { 68 + let request_uri = query.request_uri.ok_or_else(|| { 69 + OAuthError::InvalidRequest("request_uri is required".to_string()) 70 + })?; 71 + 72 + let request_data = db::get_authorization_request(&state.db, &request_uri) 73 + .await? 74 + .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; 75 + 76 + if request_data.expires_at < Utc::now() { 77 + db::delete_authorization_request(&state.db, &request_uri).await?; 78 + return Err(OAuthError::InvalidRequest("request_uri has expired".to_string())); 79 + } 80 + 81 + Ok(Json(AuthorizeResponse { 82 + client_id: request_data.parameters.client_id.clone(), 83 + client_name: None, 84 + scope: request_data.parameters.scope.clone(), 85 + redirect_uri: request_data.parameters.redirect_uri.clone(), 86 + state: request_data.parameters.state.clone(), 87 + login_hint: request_data.parameters.login_hint.clone(), 88 + })) 89 + } 90 + 91 + pub async fn authorize_post( 92 + State(state): State<AppState>, 93 + headers: HeaderMap, 94 + Form(form): Form<AuthorizeSubmit>, 95 + ) -> Result<Response, OAuthError> { 96 + let request_data = db::get_authorization_request(&state.db, &form.request_uri) 97 + .await? 98 + .ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?; 99 + 100 + if request_data.expires_at < Utc::now() { 101 + db::delete_authorization_request(&state.db, &form.request_uri).await?; 102 + return Err(OAuthError::InvalidRequest("request_uri has expired".to_string())); 103 + } 104 + 105 + let user = sqlx::query!( 106 + r#" 107 + SELECT did, password_hash, deactivated_at, takedown_ref 108 + FROM users 109 + WHERE handle = $1 OR email = $1 110 + "#, 111 + form.username 112 + ) 113 + .fetch_optional(&state.db) 114 + .await 115 + .map_err(|e| OAuthError::ServerError(e.to_string()))? 116 + .ok_or_else(|| OAuthError::AccessDenied("Invalid credentials".to_string()))?; 117 + 118 + if user.deactivated_at.is_some() { 119 + return Err(OAuthError::AccessDenied("Account is deactivated".to_string())); 120 + } 121 + 122 + if user.takedown_ref.is_some() { 123 + return Err(OAuthError::AccessDenied("Account is taken down".to_string())); 124 + } 125 + 126 + let password_valid = bcrypt::verify(&form.password, &user.password_hash) 127 + .map_err(|_| OAuthError::ServerError("Password verification failed".to_string()))?; 128 + 129 + if !password_valid { 130 + return Err(OAuthError::AccessDenied("Invalid credentials".to_string())); 131 + } 132 + 133 + let code = Code::generate(); 134 + let mut device_id: Option<String> = None; 135 + 136 + if form.remember_device { 137 + let new_device_id = DeviceId::generate(); 138 + let device_data = DeviceData { 139 + session_id: SessionId::generate().0, 140 + user_agent: extract_user_agent(&headers), 141 + ip_address: extract_client_ip(&headers), 142 + last_seen_at: Utc::now(), 143 + }; 144 + 145 + db::create_device(&state.db, &new_device_id.0, &device_data).await?; 146 + db::upsert_account_device(&state.db, &user.did, &new_device_id.0).await?; 147 + device_id = Some(new_device_id.0); 148 + } 149 + 150 + db::update_authorization_request( 151 + &state.db, 152 + &form.request_uri, 153 + &user.did, 154 + device_id.as_deref(), 155 + &code.0, 156 + ) 157 + .await?; 158 + 159 + let redirect_uri = &request_data.parameters.redirect_uri; 160 + let mut redirect_url = redirect_uri.to_string(); 161 + 162 + let separator = if redirect_url.contains('?') { '&' } else { '?' }; 163 + redirect_url.push(separator); 164 + redirect_url.push_str(&format!("code={}", url_encode(&code.0))); 165 + 166 + if let Some(state) = &request_data.parameters.state { 167 + redirect_url.push_str(&format!("&state={}", url_encode(state))); 168 + } 169 + 170 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 171 + redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname)))); 172 + 173 + Ok(Redirect::temporary(&redirect_url).into_response()) 174 + } 175 + 176 + #[derive(Debug, Serialize)] 177 + pub struct AuthorizeDenyResponse { 178 + pub error: String, 179 + pub error_description: String, 180 + } 181 + 182 + pub async fn authorize_deny( 183 + State(state): State<AppState>, 184 + Form(form): Form<AuthorizeDenyForm>, 185 + ) -> Result<Response, OAuthError> { 186 + let request_data = db::get_authorization_request(&state.db, &form.request_uri) 187 + .await? 188 + .ok_or_else(|| OAuthError::InvalidRequest("Invalid request_uri".to_string()))?; 189 + 190 + db::delete_authorization_request(&state.db, &form.request_uri).await?; 191 + 192 + let redirect_uri = &request_data.parameters.redirect_uri; 193 + let mut redirect_url = redirect_uri.to_string(); 194 + 195 + let separator = if redirect_url.contains('?') { '&' } else { '?' }; 196 + redirect_url.push(separator); 197 + redirect_url.push_str("error=access_denied"); 198 + redirect_url.push_str("&error_description=User%20denied%20the%20request"); 199 + 200 + if let Some(state) = &request_data.parameters.state { 201 + redirect_url.push_str(&format!("&state={}", url_encode(state))); 202 + } 203 + 204 + Ok(Redirect::temporary(&redirect_url).into_response()) 205 + } 206 + 207 + #[derive(Debug, Deserialize)] 208 + pub struct AuthorizeDenyForm { 209 + pub request_uri: String, 210 + }
+124
src/oauth/endpoints/metadata.rs
··· 1 + use axum::{Json, extract::State}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + use crate::state::AppState; 5 + use crate::oauth::jwks::{JwkSet, create_jwk_set}; 6 + 7 + #[derive(Debug, Serialize, Deserialize)] 8 + pub struct ProtectedResourceMetadata { 9 + pub resource: String, 10 + pub authorization_servers: Vec<String>, 11 + pub bearer_methods_supported: Vec<String>, 12 + pub scopes_supported: Vec<String>, 13 + #[serde(skip_serializing_if = "Option::is_none")] 14 + pub resource_documentation: Option<String>, 15 + } 16 + 17 + #[derive(Debug, Serialize, Deserialize)] 18 + pub struct AuthorizationServerMetadata { 19 + pub issuer: String, 20 + pub authorization_endpoint: String, 21 + pub token_endpoint: String, 22 + pub jwks_uri: String, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub registration_endpoint: Option<String>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub scopes_supported: Option<Vec<String>>, 27 + pub response_types_supported: Vec<String>, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub response_modes_supported: Option<Vec<String>>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub grant_types_supported: Option<Vec<String>>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub token_endpoint_auth_methods_supported: Option<Vec<String>>, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub code_challenge_methods_supported: Option<Vec<String>>, 36 + #[serde(skip_serializing_if = "Option::is_none")] 37 + pub pushed_authorization_request_endpoint: Option<String>, 38 + #[serde(skip_serializing_if = "Option::is_none")] 39 + pub require_pushed_authorization_requests: Option<bool>, 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + pub dpop_signing_alg_values_supported: Option<Vec<String>>, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub authorization_response_iss_parameter_supported: Option<bool>, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub revocation_endpoint: Option<String>, 46 + #[serde(skip_serializing_if = "Option::is_none")] 47 + pub introspection_endpoint: Option<String>, 48 + } 49 + 50 + pub async fn oauth_protected_resource( 51 + State(_state): State<AppState>, 52 + ) -> Json<ProtectedResourceMetadata> { 53 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 54 + let public_url = format!("https://{}", pds_hostname); 55 + 56 + Json(ProtectedResourceMetadata { 57 + resource: public_url.clone(), 58 + authorization_servers: vec![public_url], 59 + bearer_methods_supported: vec!["header".to_string()], 60 + scopes_supported: vec![], 61 + resource_documentation: Some("https://atproto.com".to_string()), 62 + }) 63 + } 64 + 65 + pub async fn oauth_authorization_server( 66 + State(_state): State<AppState>, 67 + ) -> Json<AuthorizationServerMetadata> { 68 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 69 + let issuer = format!("https://{}", pds_hostname); 70 + 71 + Json(AuthorizationServerMetadata { 72 + issuer: issuer.clone(), 73 + authorization_endpoint: format!("{}/oauth/authorize", issuer), 74 + token_endpoint: format!("{}/oauth/token", issuer), 75 + jwks_uri: format!("{}/oauth/jwks", issuer), 76 + registration_endpoint: None, 77 + scopes_supported: Some(vec![ 78 + "atproto".to_string(), 79 + "transition:generic".to_string(), 80 + "transition:chat.bsky".to_string(), 81 + ]), 82 + response_types_supported: vec!["code".to_string()], 83 + response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]), 84 + grant_types_supported: Some(vec![ 85 + "authorization_code".to_string(), 86 + "refresh_token".to_string(), 87 + ]), 88 + token_endpoint_auth_methods_supported: Some(vec![ 89 + "none".to_string(), 90 + "private_key_jwt".to_string(), 91 + ]), 92 + code_challenge_methods_supported: Some(vec!["S256".to_string()]), 93 + pushed_authorization_request_endpoint: Some(format!("{}/oauth/par", issuer)), 94 + require_pushed_authorization_requests: Some(true), 95 + dpop_signing_alg_values_supported: Some(vec![ 96 + "ES256".to_string(), 97 + "ES384".to_string(), 98 + "ES512".to_string(), 99 + "EdDSA".to_string(), 100 + ]), 101 + authorization_response_iss_parameter_supported: Some(true), 102 + revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)), 103 + introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)), 104 + }) 105 + } 106 + 107 + pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> { 108 + use crate::config::AuthConfig; 109 + use crate::oauth::jwks::Jwk; 110 + 111 + let config = AuthConfig::get(); 112 + 113 + let server_key = Jwk { 114 + kty: "EC".to_string(), 115 + key_use: Some("sig".to_string()), 116 + kid: Some(config.signing_key_id.clone()), 117 + alg: Some("ES256".to_string()), 118 + crv: Some("P-256".to_string()), 119 + x: Some(config.signing_key_x.clone()), 120 + y: Some(config.signing_key_y.clone()), 121 + }; 122 + 123 + Json(create_jwk_set(vec![server_key])) 124 + }
+9
src/oauth/endpoints/mod.rs
··· 1 + pub mod metadata; 2 + pub mod par; 3 + pub mod authorize; 4 + pub mod token; 5 + 6 + pub use metadata::*; 7 + pub use par::*; 8 + pub use authorize::*; 9 + pub use token::*;
+192
src/oauth/endpoints/par.rs
··· 1 + use axum::{ 2 + Form, Json, 3 + extract::State, 4 + }; 5 + use chrono::{Duration, Utc}; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + use crate::state::AppState; 9 + use crate::oauth::{ 10 + AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId, 11 + client::ClientMetadataCache, 12 + db, 13 + }; 14 + 15 + const PAR_EXPIRY_SECONDS: i64 = 600; 16 + 17 + const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"]; 18 + 19 + #[derive(Debug, Deserialize)] 20 + pub struct ParRequest { 21 + pub response_type: String, 22 + pub client_id: String, 23 + pub redirect_uri: String, 24 + #[serde(default)] 25 + pub scope: Option<String>, 26 + #[serde(default)] 27 + pub state: Option<String>, 28 + #[serde(default)] 29 + pub code_challenge: Option<String>, 30 + #[serde(default)] 31 + pub code_challenge_method: Option<String>, 32 + #[serde(default)] 33 + pub login_hint: Option<String>, 34 + #[serde(default)] 35 + pub dpop_jkt: Option<String>, 36 + #[serde(default)] 37 + pub client_secret: Option<String>, 38 + #[serde(default)] 39 + pub client_assertion: Option<String>, 40 + #[serde(default)] 41 + pub client_assertion_type: Option<String>, 42 + } 43 + 44 + #[derive(Debug, Serialize)] 45 + pub struct ParResponse { 46 + pub request_uri: String, 47 + pub expires_in: u64, 48 + } 49 + 50 + pub async fn pushed_authorization_request( 51 + State(state): State<AppState>, 52 + Form(request): Form<ParRequest>, 53 + ) -> Result<Json<ParResponse>, OAuthError> { 54 + if request.response_type != "code" { 55 + return Err(OAuthError::InvalidRequest( 56 + "response_type must be 'code'".to_string(), 57 + )); 58 + } 59 + 60 + let code_challenge = request.code_challenge.as_ref() 61 + .filter(|s| !s.is_empty()) 62 + .ok_or_else(|| OAuthError::InvalidRequest( 63 + "code_challenge is required".to_string(), 64 + ))?; 65 + 66 + let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or(""); 67 + if code_challenge_method != "S256" { 68 + return Err(OAuthError::InvalidRequest( 69 + "code_challenge_method must be 'S256'".to_string(), 70 + )); 71 + } 72 + 73 + let client_cache = ClientMetadataCache::new(3600); 74 + let client_metadata = client_cache.get(&request.client_id).await?; 75 + 76 + client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?; 77 + 78 + let client_auth = determine_client_auth(&request)?; 79 + 80 + if client_metadata.requires_dpop() && request.dpop_jkt.is_none() { 81 + return Err(OAuthError::InvalidRequest( 82 + "dpop_jkt is required for this client".to_string(), 83 + )); 84 + } 85 + 86 + let validated_scope = validate_scope(&request.scope, &client_metadata)?; 87 + 88 + let request_id = RequestId::generate(); 89 + let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS); 90 + 91 + let parameters = AuthorizationRequestParameters { 92 + response_type: request.response_type, 93 + client_id: request.client_id.clone(), 94 + redirect_uri: request.redirect_uri, 95 + scope: validated_scope, 96 + state: request.state, 97 + code_challenge: code_challenge.clone(), 98 + code_challenge_method: code_challenge_method.to_string(), 99 + login_hint: request.login_hint, 100 + dpop_jkt: request.dpop_jkt, 101 + extra: None, 102 + }; 103 + 104 + let request_data = RequestData { 105 + client_id: request.client_id, 106 + client_auth: Some(client_auth), 107 + parameters, 108 + expires_at, 109 + did: None, 110 + device_id: None, 111 + code: None, 112 + }; 113 + 114 + db::create_authorization_request(&state.db, &request_id.0, &request_data).await?; 115 + 116 + tokio::spawn({ 117 + let pool = state.db.clone(); 118 + async move { 119 + if let Err(e) = db::delete_expired_authorization_requests(&pool).await { 120 + tracing::warn!("Failed to cleanup expired authorization requests: {:?}", e); 121 + } 122 + } 123 + }); 124 + 125 + Ok(Json(ParResponse { 126 + request_uri: request_id.0, 127 + expires_in: PAR_EXPIRY_SECONDS as u64, 128 + })) 129 + } 130 + 131 + fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> { 132 + if let (Some(assertion), Some(assertion_type)) = 133 + (&request.client_assertion, &request.client_assertion_type) 134 + { 135 + if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { 136 + return Err(OAuthError::InvalidRequest( 137 + "Unsupported client_assertion_type".to_string(), 138 + )); 139 + } 140 + return Ok(ClientAuth::PrivateKeyJwt { 141 + client_assertion: assertion.clone(), 142 + }); 143 + } 144 + 145 + if let Some(secret) = &request.client_secret { 146 + return Ok(ClientAuth::SecretPost { 147 + client_secret: secret.clone(), 148 + }); 149 + } 150 + 151 + Ok(ClientAuth::None) 152 + } 153 + 154 + fn validate_scope( 155 + requested_scope: &Option<String>, 156 + client_metadata: &crate::oauth::client::ClientMetadata, 157 + ) -> Result<Option<String>, OAuthError> { 158 + let scope_str = match requested_scope { 159 + Some(s) if !s.is_empty() => s, 160 + _ => return Ok(Some("atproto".to_string())), 161 + }; 162 + 163 + let requested_scopes: Vec<&str> = scope_str.split_whitespace().collect(); 164 + 165 + if requested_scopes.is_empty() { 166 + return Ok(Some("atproto".to_string())); 167 + } 168 + 169 + for scope in &requested_scopes { 170 + if !SUPPORTED_SCOPES.contains(scope) { 171 + return Err(OAuthError::InvalidScope(format!( 172 + "Unsupported scope: {}. Supported scopes: {}", 173 + scope, 174 + SUPPORTED_SCOPES.join(", ") 175 + ))); 176 + } 177 + } 178 + 179 + if let Some(client_scope) = &client_metadata.scope { 180 + let client_scopes: Vec<&str> = client_scope.split_whitespace().collect(); 181 + for scope in &requested_scopes { 182 + if !client_scopes.contains(scope) { 183 + return Err(OAuthError::InvalidScope(format!( 184 + "Scope '{}' not registered for this client", 185 + scope 186 + ))); 187 + } 188 + } 189 + } 190 + 191 + Ok(Some(requested_scopes.join(" "))) 192 + }
+558
src/oauth/endpoints/token.rs
··· 1 + use axum::{ 2 + Form, Json, 3 + extract::State, 4 + http::{HeaderMap, StatusCode}, 5 + }; 6 + use base64::Engine; 7 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 8 + use chrono::{Duration, Utc}; 9 + use hmac::Mac; 10 + use serde::{Deserialize, Serialize}; 11 + use sha2::{Digest, Sha256}; 12 + use subtle::ConstantTimeEq; 13 + 14 + use crate::config::AuthConfig; 15 + use crate::state::AppState; 16 + use crate::oauth::{ 17 + ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, 18 + client::{ClientMetadataCache, verify_client_auth}, 19 + db, 20 + dpop::DPoPVerifier, 21 + }; 22 + 23 + const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; 24 + const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; 25 + 26 + #[derive(Debug, Deserialize)] 27 + pub struct TokenRequest { 28 + pub grant_type: String, 29 + #[serde(default)] 30 + pub code: Option<String>, 31 + #[serde(default)] 32 + pub redirect_uri: Option<String>, 33 + #[serde(default)] 34 + pub code_verifier: Option<String>, 35 + #[serde(default)] 36 + pub refresh_token: Option<String>, 37 + #[serde(default)] 38 + pub client_id: Option<String>, 39 + #[serde(default)] 40 + pub client_secret: Option<String>, 41 + #[serde(default)] 42 + pub client_assertion: Option<String>, 43 + #[serde(default)] 44 + pub client_assertion_type: Option<String>, 45 + } 46 + 47 + #[derive(Debug, Serialize)] 48 + pub struct TokenResponse { 49 + pub access_token: String, 50 + pub token_type: String, 51 + pub expires_in: u64, 52 + #[serde(skip_serializing_if = "Option::is_none")] 53 + pub refresh_token: Option<String>, 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub scope: Option<String>, 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub sub: Option<String>, 58 + } 59 + 60 + pub async fn token_endpoint( 61 + State(state): State<AppState>, 62 + headers: HeaderMap, 63 + Form(request): Form<TokenRequest>, 64 + ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 65 + let dpop_proof = headers 66 + .get("DPoP") 67 + .and_then(|v| v.to_str().ok()) 68 + .map(|s| s.to_string()); 69 + 70 + match request.grant_type.as_str() { 71 + "authorization_code" => { 72 + handle_authorization_code_grant(state, headers, request, dpop_proof).await 73 + } 74 + "refresh_token" => { 75 + handle_refresh_token_grant(state, headers, request, dpop_proof).await 76 + } 77 + _ => Err(OAuthError::UnsupportedGrantType(format!( 78 + "Unsupported grant_type: {}", 79 + request.grant_type 80 + ))), 81 + } 82 + } 83 + 84 + async fn handle_authorization_code_grant( 85 + state: AppState, 86 + _headers: HeaderMap, 87 + request: TokenRequest, 88 + dpop_proof: Option<String>, 89 + ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 90 + let code = request 91 + .code 92 + .ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?; 93 + 94 + let code_verifier = request 95 + .code_verifier 96 + .ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?; 97 + 98 + let auth_request = db::consume_authorization_request_by_code(&state.db, &code) 99 + .await? 100 + .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; 101 + 102 + if auth_request.expires_at < Utc::now() { 103 + return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string())); 104 + } 105 + 106 + if let Some(request_client_id) = &request.client_id { 107 + if request_client_id != &auth_request.client_id { 108 + return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); 109 + } 110 + } 111 + 112 + let did = auth_request 113 + .did 114 + .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; 115 + 116 + let client_metadata_cache = ClientMetadataCache::new(3600); 117 + let client_metadata = client_metadata_cache 118 + .get(&auth_request.client_id) 119 + .await?; 120 + let client_auth = auth_request.client_auth.clone().unwrap_or(ClientAuth::None); 121 + verify_client_auth(&client_metadata, &client_auth)?; 122 + 123 + verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; 124 + 125 + if let Some(redirect_uri) = &request.redirect_uri { 126 + if redirect_uri != &auth_request.parameters.redirect_uri { 127 + return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string())); 128 + } 129 + } 130 + 131 + let dpop_jkt = if let Some(proof) = &dpop_proof { 132 + let config = AuthConfig::get(); 133 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 134 + 135 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 136 + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 137 + 138 + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 139 + 140 + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { 141 + return Err(OAuthError::InvalidDpopProof( 142 + "DPoP proof has already been used".to_string(), 143 + )); 144 + } 145 + 146 + if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt { 147 + if &result.jkt != expected_jkt { 148 + return Err(OAuthError::InvalidDpopProof( 149 + "DPoP key binding mismatch".to_string(), 150 + )); 151 + } 152 + } 153 + 154 + Some(result.jkt) 155 + } else if auth_request.parameters.dpop_jkt.is_some() { 156 + return Err(OAuthError::InvalidRequest( 157 + "DPoP proof required for this authorization".to_string(), 158 + )); 159 + } else { 160 + None 161 + }; 162 + 163 + let token_id = TokenId::generate(); 164 + let refresh_token = RefreshToken::generate(); 165 + let now = Utc::now(); 166 + 167 + let access_token = create_access_token(&token_id.0, &did, dpop_jkt.as_deref())?; 168 + 169 + let token_data = TokenData { 170 + did: did.clone(), 171 + token_id: token_id.0.clone(), 172 + created_at: now, 173 + updated_at: now, 174 + expires_at: now + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS), 175 + client_id: auth_request.client_id.clone(), 176 + client_auth: auth_request.client_auth.unwrap_or(ClientAuth::None), 177 + device_id: auth_request.device_id, 178 + parameters: auth_request.parameters.clone(), 179 + details: None, 180 + code: None, 181 + current_refresh_token: Some(refresh_token.0.clone()), 182 + scope: auth_request.parameters.scope.clone(), 183 + }; 184 + 185 + db::create_token(&state.db, &token_data).await?; 186 + 187 + tokio::spawn({ 188 + let pool = state.db.clone(); 189 + let did_clone = did.clone(); 190 + async move { 191 + if let Err(e) = db::enforce_token_limit_for_user(&pool, &did_clone).await { 192 + tracing::warn!("Failed to enforce token limit for user: {:?}", e); 193 + } 194 + } 195 + }); 196 + 197 + let mut response_headers = HeaderMap::new(); 198 + let config = AuthConfig::get(); 199 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 200 + response_headers.insert( 201 + "DPoP-Nonce", 202 + verifier.generate_nonce().parse().unwrap(), 203 + ); 204 + 205 + Ok(( 206 + response_headers, 207 + Json(TokenResponse { 208 + access_token, 209 + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), 210 + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, 211 + refresh_token: Some(refresh_token.0), 212 + scope: auth_request.parameters.scope, 213 + sub: Some(did), 214 + }), 215 + )) 216 + } 217 + 218 + async fn handle_refresh_token_grant( 219 + state: AppState, 220 + _headers: HeaderMap, 221 + request: TokenRequest, 222 + dpop_proof: Option<String>, 223 + ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 224 + let refresh_token_str = request 225 + .refresh_token 226 + .ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?; 227 + 228 + if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { 229 + db::delete_token_family(&state.db, token_id).await?; 230 + return Err(OAuthError::InvalidGrant( 231 + "Refresh token reuse detected, token family revoked".to_string(), 232 + )); 233 + } 234 + 235 + let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str) 236 + .await? 237 + .ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?; 238 + 239 + if token_data.expires_at < Utc::now() { 240 + db::delete_token_family(&state.db, db_id).await?; 241 + return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string())); 242 + } 243 + 244 + let dpop_jkt = if let Some(proof) = &dpop_proof { 245 + let config = AuthConfig::get(); 246 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 247 + 248 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 249 + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); 250 + 251 + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; 252 + 253 + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { 254 + return Err(OAuthError::InvalidDpopProof( 255 + "DPoP proof has already been used".to_string(), 256 + )); 257 + } 258 + 259 + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { 260 + if &result.jkt != expected_jkt { 261 + return Err(OAuthError::InvalidDpopProof( 262 + "DPoP key binding mismatch".to_string(), 263 + )); 264 + } 265 + } 266 + 267 + Some(result.jkt) 268 + } else if token_data.parameters.dpop_jkt.is_some() { 269 + return Err(OAuthError::InvalidRequest( 270 + "DPoP proof required".to_string(), 271 + )); 272 + } else { 273 + None 274 + }; 275 + 276 + let new_token_id = TokenId::generate(); 277 + let new_refresh_token = RefreshToken::generate(); 278 + let new_expires_at = Utc::now() + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS); 279 + 280 + db::rotate_token( 281 + &state.db, 282 + db_id, 283 + &new_token_id.0, 284 + &new_refresh_token.0, 285 + new_expires_at, 286 + ) 287 + .await?; 288 + 289 + let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?; 290 + 291 + let mut response_headers = HeaderMap::new(); 292 + let config = AuthConfig::get(); 293 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 294 + response_headers.insert( 295 + "DPoP-Nonce", 296 + verifier.generate_nonce().parse().unwrap(), 297 + ); 298 + 299 + Ok(( 300 + response_headers, 301 + Json(TokenResponse { 302 + access_token, 303 + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), 304 + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, 305 + refresh_token: Some(new_refresh_token.0), 306 + scope: token_data.scope, 307 + sub: Some(token_data.did), 308 + }), 309 + )) 310 + } 311 + 312 + fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> { 313 + use subtle::ConstantTimeEq; 314 + 315 + let mut hasher = Sha256::new(); 316 + hasher.update(code_verifier.as_bytes()); 317 + let hash = hasher.finalize(); 318 + let computed_challenge = URL_SAFE_NO_PAD.encode(&hash); 319 + 320 + if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) { 321 + return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string())); 322 + } 323 + 324 + Ok(()) 325 + } 326 + 327 + fn create_access_token( 328 + token_id: &str, 329 + sub: &str, 330 + dpop_jkt: Option<&str>, 331 + ) -> Result<String, OAuthError> { 332 + use serde_json::json; 333 + 334 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 335 + let issuer = format!("https://{}", pds_hostname); 336 + 337 + let now = Utc::now().timestamp(); 338 + let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; 339 + 340 + let mut payload = json!({ 341 + "iss": issuer, 342 + "sub": sub, 343 + "aud": issuer, 344 + "iat": now, 345 + "exp": exp, 346 + "jti": token_id, 347 + "scope": "atproto" 348 + }); 349 + 350 + if let Some(jkt) = dpop_jkt { 351 + payload["cnf"] = json!({ "jkt": jkt }); 352 + } 353 + 354 + let header = json!({ 355 + "alg": "HS256", 356 + "typ": "at+jwt" 357 + }); 358 + 359 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 360 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 361 + 362 + let signing_input = format!("{}.{}", header_b64, payload_b64); 363 + 364 + let config = AuthConfig::get(); 365 + 366 + use sha2::Sha256 as HmacSha256; 367 + use hmac::{Hmac, Mac}; 368 + type HmacSha256Type = Hmac<HmacSha256>; 369 + 370 + let mut mac = HmacSha256Type::new_from_slice(config.jwt_secret().as_bytes()) 371 + .map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?; 372 + mac.update(signing_input.as_bytes()); 373 + let signature = mac.finalize().into_bytes(); 374 + 375 + let signature_b64 = URL_SAFE_NO_PAD.encode(&signature); 376 + 377 + Ok(format!("{}.{}", signing_input, signature_b64)) 378 + } 379 + 380 + pub async fn revoke_token( 381 + State(state): State<AppState>, 382 + Form(request): Form<RevokeRequest>, 383 + ) -> Result<StatusCode, OAuthError> { 384 + if let Some(token) = &request.token { 385 + if let Some((db_id, _)) = db::get_token_by_refresh_token(&state.db, token).await? { 386 + db::delete_token_family(&state.db, db_id).await?; 387 + } else { 388 + db::delete_token(&state.db, token).await?; 389 + } 390 + } 391 + 392 + Ok(StatusCode::OK) 393 + } 394 + 395 + #[derive(Debug, Deserialize)] 396 + pub struct RevokeRequest { 397 + pub token: Option<String>, 398 + #[serde(default)] 399 + pub token_type_hint: Option<String>, 400 + } 401 + 402 + #[derive(Debug, Deserialize)] 403 + pub struct IntrospectRequest { 404 + pub token: String, 405 + #[serde(default)] 406 + pub token_type_hint: Option<String>, 407 + } 408 + 409 + #[derive(Debug, Serialize)] 410 + pub struct IntrospectResponse { 411 + pub active: bool, 412 + #[serde(skip_serializing_if = "Option::is_none")] 413 + pub scope: Option<String>, 414 + #[serde(skip_serializing_if = "Option::is_none")] 415 + pub client_id: Option<String>, 416 + #[serde(skip_serializing_if = "Option::is_none")] 417 + pub username: Option<String>, 418 + #[serde(skip_serializing_if = "Option::is_none")] 419 + pub token_type: Option<String>, 420 + #[serde(skip_serializing_if = "Option::is_none")] 421 + pub exp: Option<i64>, 422 + #[serde(skip_serializing_if = "Option::is_none")] 423 + pub iat: Option<i64>, 424 + #[serde(skip_serializing_if = "Option::is_none")] 425 + pub nbf: Option<i64>, 426 + #[serde(skip_serializing_if = "Option::is_none")] 427 + pub sub: Option<String>, 428 + #[serde(skip_serializing_if = "Option::is_none")] 429 + pub aud: Option<String>, 430 + #[serde(skip_serializing_if = "Option::is_none")] 431 + pub iss: Option<String>, 432 + #[serde(skip_serializing_if = "Option::is_none")] 433 + pub jti: Option<String>, 434 + } 435 + 436 + pub async fn introspect_token( 437 + State(state): State<AppState>, 438 + Form(request): Form<IntrospectRequest>, 439 + ) -> Json<IntrospectResponse> { 440 + let inactive_response = IntrospectResponse { 441 + active: false, 442 + scope: None, 443 + client_id: None, 444 + username: None, 445 + token_type: None, 446 + exp: None, 447 + iat: None, 448 + nbf: None, 449 + sub: None, 450 + aud: None, 451 + iss: None, 452 + jti: None, 453 + }; 454 + 455 + let token_info = match extract_token_claims(&request.token) { 456 + Ok(info) => info, 457 + Err(_) => return Json(inactive_response), 458 + }; 459 + 460 + let token_data = match db::get_token_by_id(&state.db, &token_info.jti).await { 461 + Ok(Some(data)) => data, 462 + _ => return Json(inactive_response), 463 + }; 464 + 465 + if token_data.expires_at < Utc::now() { 466 + return Json(inactive_response); 467 + } 468 + 469 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 470 + let issuer = format!("https://{}", pds_hostname); 471 + 472 + Json(IntrospectResponse { 473 + active: true, 474 + scope: token_data.scope, 475 + client_id: Some(token_data.client_id), 476 + username: None, 477 + token_type: if token_data.parameters.dpop_jkt.is_some() { 478 + Some("DPoP".to_string()) 479 + } else { 480 + Some("Bearer".to_string()) 481 + }, 482 + exp: Some(token_info.exp), 483 + iat: Some(token_info.iat), 484 + nbf: Some(token_info.iat), 485 + sub: Some(token_data.did), 486 + aud: Some(issuer.clone()), 487 + iss: Some(issuer), 488 + jti: Some(token_info.jti), 489 + }) 490 + } 491 + 492 + struct TokenClaims { 493 + jti: String, 494 + exp: i64, 495 + iat: i64, 496 + } 497 + 498 + fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> { 499 + let parts: Vec<&str> = token.split('.').collect(); 500 + if parts.len() != 3 { 501 + return Err(OAuthError::InvalidToken("Invalid token format".to_string())); 502 + } 503 + 504 + let header_bytes = URL_SAFE_NO_PAD 505 + .decode(parts[0]) 506 + .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; 507 + let header: serde_json::Value = serde_json::from_slice(&header_bytes) 508 + .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; 509 + 510 + if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { 511 + return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); 512 + } 513 + if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { 514 + return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); 515 + } 516 + 517 + let config = AuthConfig::get(); 518 + let secret = config.jwt_secret(); 519 + 520 + let signing_input = format!("{}.{}", parts[0], parts[1]); 521 + let provided_sig = URL_SAFE_NO_PAD 522 + .decode(parts[2]) 523 + .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; 524 + 525 + type HmacSha256 = hmac::Hmac<Sha256>; 526 + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) 527 + .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; 528 + mac.update(signing_input.as_bytes()); 529 + let expected_sig = mac.finalize().into_bytes(); 530 + 531 + if !bool::from(expected_sig.ct_eq(&provided_sig)) { 532 + return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); 533 + } 534 + 535 + let payload_bytes = URL_SAFE_NO_PAD 536 + .decode(parts[1]) 537 + .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; 538 + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) 539 + .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; 540 + 541 + let jti = payload 542 + .get("jti") 543 + .and_then(|j| j.as_str()) 544 + .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 545 + .to_string(); 546 + 547 + let exp = payload 548 + .get("exp") 549 + .and_then(|e| e.as_i64()) 550 + .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 551 + 552 + let iat = payload 553 + .get("iat") 554 + .and_then(|i| i.as_i64()) 555 + .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; 556 + 557 + Ok(TokenClaims { jti, exp, iat }) 558 + }
+102
src/oauth/error.rs
··· 1 + use axum::{ 2 + Json, 3 + http::StatusCode, 4 + response::{IntoResponse, Response}, 5 + }; 6 + use serde::Serialize; 7 + 8 + #[derive(Debug)] 9 + pub enum OAuthError { 10 + InvalidRequest(String), 11 + InvalidClient(String), 12 + InvalidGrant(String), 13 + UnauthorizedClient(String), 14 + UnsupportedGrantType(String), 15 + InvalidScope(String), 16 + AccessDenied(String), 17 + ServerError(String), 18 + UseDpopNonce(String), 19 + InvalidDpopProof(String), 20 + ExpiredToken(String), 21 + InvalidToken(String), 22 + } 23 + 24 + #[derive(Serialize)] 25 + struct OAuthErrorResponse { 26 + error: String, 27 + error_description: Option<String>, 28 + } 29 + 30 + impl IntoResponse for OAuthError { 31 + fn into_response(self) -> Response { 32 + let (status, error, description) = match self { 33 + OAuthError::InvalidRequest(msg) => { 34 + (StatusCode::BAD_REQUEST, "invalid_request", Some(msg)) 35 + } 36 + OAuthError::InvalidClient(msg) => { 37 + (StatusCode::UNAUTHORIZED, "invalid_client", Some(msg)) 38 + } 39 + OAuthError::InvalidGrant(msg) => { 40 + (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)) 41 + } 42 + OAuthError::UnauthorizedClient(msg) => { 43 + (StatusCode::UNAUTHORIZED, "unauthorized_client", Some(msg)) 44 + } 45 + OAuthError::UnsupportedGrantType(msg) => { 46 + (StatusCode::BAD_REQUEST, "unsupported_grant_type", Some(msg)) 47 + } 48 + OAuthError::InvalidScope(msg) => { 49 + (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)) 50 + } 51 + OAuthError::AccessDenied(msg) => { 52 + (StatusCode::FORBIDDEN, "access_denied", Some(msg)) 53 + } 54 + OAuthError::ServerError(msg) => { 55 + (StatusCode::INTERNAL_SERVER_ERROR, "server_error", Some(msg)) 56 + } 57 + OAuthError::UseDpopNonce(nonce) => { 58 + return ( 59 + StatusCode::BAD_REQUEST, 60 + [("DPoP-Nonce", nonce)], 61 + Json(OAuthErrorResponse { 62 + error: "use_dpop_nonce".to_string(), 63 + error_description: Some("A DPoP nonce is required".to_string()), 64 + }), 65 + ) 66 + .into_response(); 67 + } 68 + OAuthError::InvalidDpopProof(msg) => { 69 + (StatusCode::UNAUTHORIZED, "invalid_dpop_proof", Some(msg)) 70 + } 71 + OAuthError::ExpiredToken(msg) => { 72 + (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) 73 + } 74 + OAuthError::InvalidToken(msg) => { 75 + (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)) 76 + } 77 + }; 78 + 79 + ( 80 + status, 81 + Json(OAuthErrorResponse { 82 + error: error.to_string(), 83 + error_description: description, 84 + }), 85 + ) 86 + .into_response() 87 + } 88 + } 89 + 90 + impl From<sqlx::Error> for OAuthError { 91 + fn from(err: sqlx::Error) -> Self { 92 + tracing::error!("Database error in OAuth flow: {}", err); 93 + OAuthError::ServerError("An internal error occurred".to_string()) 94 + } 95 + } 96 + 97 + impl From<anyhow::Error> for OAuthError { 98 + fn from(err: anyhow::Error) -> Self { 99 + tracing::error!("Internal error in OAuth flow: {}", err); 100 + OAuthError::ServerError("An internal error occurred".to_string()) 101 + } 102 + }
+27
src/oauth/jwks.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Serialize, Deserialize)] 4 + pub struct JwkSet { 5 + pub keys: Vec<Jwk>, 6 + } 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct Jwk { 10 + pub kty: String, 11 + #[serde(rename = "use", skip_serializing_if = "Option::is_none")] 12 + pub key_use: Option<String>, 13 + #[serde(skip_serializing_if = "Option::is_none")] 14 + pub kid: Option<String>, 15 + #[serde(skip_serializing_if = "Option::is_none")] 16 + pub alg: Option<String>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + pub crv: Option<String>, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub x: Option<String>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub y: Option<String>, 23 + } 24 + 25 + pub fn create_jwk_set(keys: Vec<Jwk>) -> JwkSet { 26 + JwkSet { keys } 27 + }
+12
src/oauth/mod.rs
··· 1 + pub mod types; 2 + pub mod db; 3 + pub mod dpop; 4 + pub mod jwks; 5 + pub mod client; 6 + pub mod endpoints; 7 + pub mod error; 8 + pub mod verify; 9 + 10 + pub use types::*; 11 + pub use error::OAuthError; 12 + pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError};
+241
src/oauth/types.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + use serde_json::Value as JsonValue; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + pub struct RequestId(pub String); 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct TokenId(pub String); 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + pub struct DeviceId(pub String); 13 + 14 + #[derive(Debug, Clone, Serialize, Deserialize)] 15 + pub struct SessionId(pub String); 16 + 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + pub struct Code(pub String); 19 + 20 + #[derive(Debug, Clone, Serialize, Deserialize)] 21 + pub struct RefreshToken(pub String); 22 + 23 + impl RequestId { 24 + pub fn generate() -> Self { 25 + Self(format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4())) 26 + } 27 + } 28 + 29 + impl TokenId { 30 + pub fn generate() -> Self { 31 + Self(uuid::Uuid::new_v4().to_string()) 32 + } 33 + } 34 + 35 + impl DeviceId { 36 + pub fn generate() -> Self { 37 + Self(uuid::Uuid::new_v4().to_string()) 38 + } 39 + } 40 + 41 + impl SessionId { 42 + pub fn generate() -> Self { 43 + Self(uuid::Uuid::new_v4().to_string()) 44 + } 45 + } 46 + 47 + impl Code { 48 + pub fn generate() -> Self { 49 + use rand::Rng; 50 + let bytes: [u8; 32] = rand::thread_rng().r#gen(); 51 + Self(base64::Engine::encode( 52 + &base64::engine::general_purpose::URL_SAFE_NO_PAD, 53 + bytes, 54 + )) 55 + } 56 + } 57 + 58 + impl RefreshToken { 59 + pub fn generate() -> Self { 60 + use rand::Rng; 61 + let bytes: [u8; 32] = rand::thread_rng().r#gen(); 62 + Self(base64::Engine::encode( 63 + &base64::engine::general_purpose::URL_SAFE_NO_PAD, 64 + bytes, 65 + )) 66 + } 67 + } 68 + 69 + #[derive(Debug, Clone, Serialize, Deserialize)] 70 + #[serde(tag = "method")] 71 + pub enum ClientAuth { 72 + #[serde(rename = "none")] 73 + None, 74 + #[serde(rename = "client_secret_basic")] 75 + SecretBasic { client_secret: String }, 76 + #[serde(rename = "client_secret_post")] 77 + SecretPost { client_secret: String }, 78 + #[serde(rename = "private_key_jwt")] 79 + PrivateKeyJwt { client_assertion: String }, 80 + } 81 + 82 + #[derive(Debug, Clone, Serialize, Deserialize)] 83 + pub struct AuthorizationRequestParameters { 84 + pub response_type: String, 85 + pub client_id: String, 86 + pub redirect_uri: String, 87 + pub scope: Option<String>, 88 + pub state: Option<String>, 89 + pub code_challenge: String, 90 + pub code_challenge_method: String, 91 + pub login_hint: Option<String>, 92 + pub dpop_jkt: Option<String>, 93 + #[serde(flatten)] 94 + pub extra: Option<JsonValue>, 95 + } 96 + 97 + #[derive(Debug, Clone)] 98 + pub struct RequestData { 99 + pub client_id: String, 100 + pub client_auth: Option<ClientAuth>, 101 + pub parameters: AuthorizationRequestParameters, 102 + pub expires_at: DateTime<Utc>, 103 + pub did: Option<String>, 104 + pub device_id: Option<String>, 105 + pub code: Option<String>, 106 + } 107 + 108 + #[derive(Debug, Clone)] 109 + pub struct DeviceData { 110 + pub session_id: String, 111 + pub user_agent: Option<String>, 112 + pub ip_address: String, 113 + pub last_seen_at: DateTime<Utc>, 114 + } 115 + 116 + #[derive(Debug, Clone)] 117 + pub struct TokenData { 118 + pub did: String, 119 + pub token_id: String, 120 + pub created_at: DateTime<Utc>, 121 + pub updated_at: DateTime<Utc>, 122 + pub expires_at: DateTime<Utc>, 123 + pub client_id: String, 124 + pub client_auth: ClientAuth, 125 + pub device_id: Option<String>, 126 + pub parameters: AuthorizationRequestParameters, 127 + pub details: Option<JsonValue>, 128 + pub code: Option<String>, 129 + pub current_refresh_token: Option<String>, 130 + pub scope: Option<String>, 131 + } 132 + 133 + #[derive(Debug, Clone, Serialize, Deserialize)] 134 + pub struct AuthorizedClientData { 135 + pub scope: Option<String>, 136 + pub remember: bool, 137 + } 138 + 139 + #[derive(Debug, Clone, Serialize, Deserialize)] 140 + pub struct OAuthClientMetadata { 141 + pub client_id: String, 142 + pub client_name: Option<String>, 143 + pub client_uri: Option<String>, 144 + pub logo_uri: Option<String>, 145 + pub redirect_uris: Vec<String>, 146 + pub grant_types: Option<Vec<String>>, 147 + pub response_types: Option<Vec<String>>, 148 + pub scope: Option<String>, 149 + pub token_endpoint_auth_method: Option<String>, 150 + pub dpop_bound_access_tokens: Option<bool>, 151 + pub jwks: Option<JsonValue>, 152 + pub jwks_uri: Option<String>, 153 + pub application_type: Option<String>, 154 + } 155 + 156 + #[derive(Debug, Clone, Serialize, Deserialize)] 157 + pub struct ProtectedResourceMetadata { 158 + pub resource: String, 159 + pub authorization_servers: Vec<String>, 160 + pub bearer_methods_supported: Vec<String>, 161 + pub scopes_supported: Vec<String>, 162 + pub resource_documentation: Option<String>, 163 + } 164 + 165 + #[derive(Debug, Clone, Serialize, Deserialize)] 166 + pub struct AuthorizationServerMetadata { 167 + pub issuer: String, 168 + pub authorization_endpoint: String, 169 + pub token_endpoint: String, 170 + pub jwks_uri: String, 171 + pub registration_endpoint: Option<String>, 172 + pub scopes_supported: Option<Vec<String>>, 173 + pub response_types_supported: Vec<String>, 174 + pub response_modes_supported: Option<Vec<String>>, 175 + pub grant_types_supported: Option<Vec<String>>, 176 + pub token_endpoint_auth_methods_supported: Option<Vec<String>>, 177 + pub code_challenge_methods_supported: Option<Vec<String>>, 178 + pub pushed_authorization_request_endpoint: Option<String>, 179 + pub require_pushed_authorization_requests: Option<bool>, 180 + pub dpop_signing_alg_values_supported: Option<Vec<String>>, 181 + pub authorization_response_iss_parameter_supported: Option<bool>, 182 + } 183 + 184 + #[derive(Debug, Clone, Serialize, Deserialize)] 185 + pub struct ParResponse { 186 + pub request_uri: String, 187 + pub expires_in: u64, 188 + } 189 + 190 + #[derive(Debug, Clone, Serialize, Deserialize)] 191 + pub struct TokenResponse { 192 + pub access_token: String, 193 + pub token_type: String, 194 + pub expires_in: u64, 195 + #[serde(skip_serializing_if = "Option::is_none")] 196 + pub refresh_token: Option<String>, 197 + #[serde(skip_serializing_if = "Option::is_none")] 198 + pub scope: Option<String>, 199 + #[serde(skip_serializing_if = "Option::is_none")] 200 + pub sub: Option<String>, 201 + } 202 + 203 + #[derive(Debug, Clone, Serialize, Deserialize)] 204 + pub struct TokenRequest { 205 + pub grant_type: String, 206 + pub code: Option<String>, 207 + pub redirect_uri: Option<String>, 208 + pub code_verifier: Option<String>, 209 + pub refresh_token: Option<String>, 210 + pub client_id: Option<String>, 211 + pub client_secret: Option<String>, 212 + } 213 + 214 + #[derive(Debug, Clone, Serialize, Deserialize)] 215 + pub struct DPoPClaims { 216 + pub jti: String, 217 + pub htm: String, 218 + pub htu: String, 219 + pub iat: i64, 220 + #[serde(skip_serializing_if = "Option::is_none")] 221 + pub ath: Option<String>, 222 + #[serde(skip_serializing_if = "Option::is_none")] 223 + pub nonce: Option<String>, 224 + } 225 + 226 + #[derive(Debug, Clone, Serialize, Deserialize)] 227 + pub struct JwkPublicKey { 228 + pub kty: String, 229 + pub crv: Option<String>, 230 + pub x: Option<String>, 231 + pub y: Option<String>, 232 + #[serde(rename = "use")] 233 + pub key_use: Option<String>, 234 + pub kid: Option<String>, 235 + pub alg: Option<String>, 236 + } 237 + 238 + #[derive(Debug, Clone, Serialize, Deserialize)] 239 + pub struct Jwks { 240 + pub keys: Vec<JwkPublicKey>, 241 + }
+312
src/oauth/verify.rs
··· 1 + use axum::{ 2 + extract::FromRequestParts, 3 + http::{StatusCode, request::Parts}, 4 + response::{IntoResponse, Response}, 5 + Json, 6 + }; 7 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 + use hmac::{Hmac, Mac}; 9 + use serde_json::json; 10 + use sha2::Sha256; 11 + use sqlx::PgPool; 12 + use subtle::ConstantTimeEq; 13 + 14 + use crate::config::AuthConfig; 15 + use crate::state::AppState; 16 + use super::db; 17 + use super::dpop::DPoPVerifier; 18 + use super::OAuthError; 19 + 20 + pub struct OAuthTokenInfo { 21 + pub did: String, 22 + pub token_id: String, 23 + pub client_id: String, 24 + pub scope: Option<String>, 25 + pub dpop_jkt: Option<String>, 26 + } 27 + 28 + pub struct VerifyResult { 29 + pub did: String, 30 + pub token_id: String, 31 + pub client_id: String, 32 + pub scope: Option<String>, 33 + } 34 + 35 + pub async fn verify_oauth_access_token( 36 + pool: &PgPool, 37 + access_token: &str, 38 + dpop_proof: Option<&str>, 39 + http_method: &str, 40 + http_uri: &str, 41 + ) -> Result<VerifyResult, OAuthError> { 42 + let token_info = extract_oauth_token_info(access_token)?; 43 + 44 + let token_data = db::get_token_by_id(pool, &token_info.token_id) 45 + .await? 46 + .ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?; 47 + 48 + let now = chrono::Utc::now(); 49 + if token_data.expires_at < now { 50 + return Err(OAuthError::InvalidToken("Token has expired".to_string())); 51 + } 52 + 53 + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { 54 + let proof = dpop_proof.ok_or_else(|| { 55 + OAuthError::UseDpopNonce("DPoP proof required".to_string()) 56 + })?; 57 + 58 + let config = AuthConfig::get(); 59 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 60 + 61 + let access_token_hash = compute_ath(access_token); 62 + let result = verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; 63 + 64 + if !db::check_and_record_dpop_jti(pool, &result.jti).await? { 65 + return Err(OAuthError::InvalidDpopProof( 66 + "DPoP proof has already been used".to_string(), 67 + )); 68 + } 69 + 70 + if &result.jkt != expected_jkt { 71 + return Err(OAuthError::InvalidDpopProof( 72 + "DPoP key binding mismatch".to_string(), 73 + )); 74 + } 75 + } 76 + 77 + Ok(VerifyResult { 78 + did: token_data.did, 79 + token_id: token_info.token_id, 80 + client_id: token_data.client_id, 81 + scope: token_data.scope, 82 + }) 83 + } 84 + 85 + pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthError> { 86 + let parts: Vec<&str> = token.split('.').collect(); 87 + if parts.len() != 3 { 88 + return Err(OAuthError::InvalidToken("Invalid token format".to_string())); 89 + } 90 + 91 + let header_bytes = URL_SAFE_NO_PAD 92 + .decode(parts[0]) 93 + .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; 94 + let header: serde_json::Value = serde_json::from_slice(&header_bytes) 95 + .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; 96 + 97 + if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { 98 + return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); 99 + } 100 + if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { 101 + return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); 102 + } 103 + 104 + let config = AuthConfig::get(); 105 + let secret = config.jwt_secret(); 106 + 107 + let signing_input = format!("{}.{}", parts[0], parts[1]); 108 + let provided_sig = URL_SAFE_NO_PAD 109 + .decode(parts[2]) 110 + .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; 111 + 112 + type HmacSha256 = Hmac<Sha256>; 113 + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) 114 + .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; 115 + mac.update(signing_input.as_bytes()); 116 + let expected_sig = mac.finalize().into_bytes(); 117 + 118 + if !bool::from(expected_sig.ct_eq(&provided_sig)) { 119 + return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); 120 + } 121 + 122 + let payload_bytes = URL_SAFE_NO_PAD 123 + .decode(parts[1]) 124 + .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; 125 + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) 126 + .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; 127 + 128 + let exp = payload 129 + .get("exp") 130 + .and_then(|e| e.as_i64()) 131 + .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 132 + let now = chrono::Utc::now().timestamp(); 133 + if exp < now { 134 + return Err(OAuthError::InvalidToken("Token has expired".to_string())); 135 + } 136 + 137 + let token_id = payload 138 + .get("jti") 139 + .and_then(|j| j.as_str()) 140 + .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 141 + .to_string(); 142 + 143 + let did = payload 144 + .get("sub") 145 + .and_then(|s| s.as_str()) 146 + .ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))? 147 + .to_string(); 148 + 149 + let scope = payload.get("scope").and_then(|s| s.as_str()).map(|s| s.to_string()); 150 + 151 + let dpop_jkt = payload 152 + .get("cnf") 153 + .and_then(|c| c.get("jkt")) 154 + .and_then(|j| j.as_str()) 155 + .map(|s| s.to_string()); 156 + 157 + let client_id = payload 158 + .get("client_id") 159 + .and_then(|c| c.as_str()) 160 + .map(|s| s.to_string()) 161 + .unwrap_or_default(); 162 + 163 + Ok(OAuthTokenInfo { 164 + did, 165 + token_id, 166 + client_id, 167 + scope, 168 + dpop_jkt, 169 + }) 170 + } 171 + 172 + fn compute_ath(access_token: &str) -> String { 173 + use sha2::Digest; 174 + let mut hasher = Sha256::new(); 175 + hasher.update(access_token.as_bytes()); 176 + let hash = hasher.finalize(); 177 + URL_SAFE_NO_PAD.encode(&hash) 178 + } 179 + 180 + pub fn generate_dpop_nonce() -> String { 181 + let config = AuthConfig::get(); 182 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 183 + verifier.generate_nonce() 184 + } 185 + 186 + pub struct OAuthUser { 187 + pub did: String, 188 + pub client_id: Option<String>, 189 + pub scope: Option<String>, 190 + pub is_oauth: bool, 191 + } 192 + 193 + pub struct OAuthAuthError { 194 + pub status: StatusCode, 195 + pub error: String, 196 + pub message: String, 197 + pub dpop_nonce: Option<String>, 198 + } 199 + 200 + impl IntoResponse for OAuthAuthError { 201 + fn into_response(self) -> Response { 202 + let mut response = ( 203 + self.status, 204 + Json(json!({ 205 + "error": self.error, 206 + "message": self.message 207 + })), 208 + ) 209 + .into_response(); 210 + 211 + if let Some(nonce) = self.dpop_nonce { 212 + response.headers_mut().insert( 213 + "DPoP-Nonce", 214 + nonce.parse().unwrap(), 215 + ); 216 + } 217 + 218 + response 219 + } 220 + } 221 + 222 + impl FromRequestParts<AppState> for OAuthUser { 223 + type Rejection = OAuthAuthError; 224 + 225 + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> { 226 + let auth_header = parts 227 + .headers 228 + .get("Authorization") 229 + .and_then(|v| v.to_str().ok()) 230 + .ok_or_else(|| OAuthAuthError { 231 + status: StatusCode::UNAUTHORIZED, 232 + error: "AuthenticationRequired".to_string(), 233 + message: "Authorization header required".to_string(), 234 + dpop_nonce: None, 235 + })?; 236 + 237 + let auth_header_trimmed = auth_header.trim(); 238 + let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") { 239 + (auth_header_trimmed[7..].trim(), false) 240 + } else if auth_header_trimmed.len() >= 5 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") { 241 + (auth_header_trimmed[5..].trim(), true) 242 + } else { 243 + return Err(OAuthAuthError { 244 + status: StatusCode::UNAUTHORIZED, 245 + error: "InvalidRequest".to_string(), 246 + message: "Invalid authorization scheme".to_string(), 247 + dpop_nonce: None, 248 + }); 249 + }; 250 + 251 + let dpop_proof = parts 252 + .headers 253 + .get("DPoP") 254 + .and_then(|v| v.to_str().ok()); 255 + 256 + if let Ok(result) = try_legacy_auth(&state.db, token).await { 257 + return Ok(OAuthUser { 258 + did: result.did, 259 + client_id: None, 260 + scope: None, 261 + is_oauth: false, 262 + }); 263 + } 264 + 265 + let http_method = parts.method.as_str(); 266 + let http_uri = parts.uri.to_string(); 267 + 268 + match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await { 269 + Ok(result) => Ok(OAuthUser { 270 + did: result.did, 271 + client_id: Some(result.client_id), 272 + scope: result.scope, 273 + is_oauth: true, 274 + }), 275 + Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError { 276 + status: StatusCode::UNAUTHORIZED, 277 + error: "use_dpop_nonce".to_string(), 278 + message: "DPoP nonce required".to_string(), 279 + dpop_nonce: Some(nonce), 280 + }), 281 + Err(OAuthError::InvalidDpopProof(msg)) => { 282 + let nonce = generate_dpop_nonce(); 283 + Err(OAuthAuthError { 284 + status: StatusCode::UNAUTHORIZED, 285 + error: "invalid_dpop_proof".to_string(), 286 + message: msg, 287 + dpop_nonce: Some(nonce), 288 + }) 289 + } 290 + Err(e) => { 291 + let nonce = if is_dpop_token { Some(generate_dpop_nonce()) } else { None }; 292 + Err(OAuthAuthError { 293 + status: StatusCode::UNAUTHORIZED, 294 + error: "AuthenticationFailed".to_string(), 295 + message: format!("{:?}", e), 296 + dpop_nonce: nonce, 297 + }) 298 + } 299 + } 300 + } 301 + } 302 + 303 + struct LegacyAuthResult { 304 + did: String, 305 + } 306 + 307 + async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> { 308 + match crate::auth::validate_bearer_token(pool, token).await { 309 + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), 310 + _ => Err(()), 311 + } 312 + }
+3
src/state.rs
··· 1 + use crate::config::AuthConfig; 1 2 use crate::repo::PostgresBlockStore; 2 3 use crate::storage::{BlobStorage, S3BlobStorage}; 3 4 use crate::sync::firehose::SequencedEvent; ··· 15 16 16 17 impl AppState { 17 18 pub async fn new(db: PgPool) -> Self { 19 + AuthConfig::init(); 20 + 18 21 let block_store = PostgresBlockStore::new(db.clone()); 19 22 let blob_store = S3BlobStorage::new().await; 20 23 let (firehose_tx, _) = broadcast::channel(1000);
+20 -4
tests/auth.rs
··· 13 13 let did = "did:plc:test"; 14 14 15 15 let token = auth::create_access_token(did, &key_bytes).expect("create token"); 16 - let data = auth::verify_token(&token, &key_bytes).expect("verify token"); 16 + let data = auth::verify_access_token(&token, &key_bytes).expect("verify access token"); 17 17 assert_eq!(data.claims.sub, did); 18 18 assert_eq!(data.claims.iss, did); 19 - assert_eq!(data.claims.scope, Some("access".to_string())); 19 + assert_eq!(data.claims.scope, Some(auth::SCOPE_ACCESS.to_string())); 20 20 21 21 let r_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token"); 22 - let r_data = auth::verify_token(&r_token, &key_bytes).expect("verify refresh token"); 23 - assert_eq!(r_data.claims.scope, Some("refresh".to_string())); 22 + let r_data = auth::verify_refresh_token(&r_token, &key_bytes).expect("verify refresh token"); 23 + assert_eq!(r_data.claims.scope, Some(auth::SCOPE_REFRESH.to_string())); 24 24 25 25 let aud = "did:web:service"; 26 26 let lxm = "com.example.test"; ··· 29 29 let s_data = auth::verify_token(&s_token, &key_bytes).expect("verify service token"); 30 30 assert_eq!(s_data.claims.aud, aud); 31 31 assert_eq!(s_data.claims.lxm, Some(lxm.to_string())); 32 + } 33 + 34 + #[test] 35 + fn test_token_type_confusion_prevented() { 36 + let secret_key = SecretKey::random(&mut OsRng); 37 + let key_bytes = secret_key.to_bytes(); 38 + let did = "did:plc:test"; 39 + 40 + let access_token = auth::create_access_token(did, &key_bytes).expect("create access token"); 41 + let refresh_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token"); 42 + 43 + assert!(auth::verify_access_token(&access_token, &key_bytes).is_ok()); 44 + assert!(auth::verify_access_token(&refresh_token, &key_bytes).is_err()); 45 + 46 + assert!(auth::verify_refresh_token(&refresh_token, &key_bytes).is_ok()); 47 + assert!(auth::verify_refresh_token(&access_token, &key_bytes).is_err()); 32 48 } 33 49 34 50 #[test]
+160 -91
tests/common/mod.rs
··· 11 11 use std::sync::OnceLock; 12 12 #[allow(unused_imports)] 13 13 use std::time::Duration; 14 - use testcontainers::core::ContainerPort; 15 - use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; 16 - use testcontainers_modules::postgres::Postgres; 17 14 use tokio::net::TcpListener; 18 15 use wiremock::matchers::{method, path}; 19 16 use wiremock::{Mock, MockServer, ResponseTemplate}; 20 17 21 18 static SERVER_URL: OnceLock<String> = OnceLock::new(); 22 19 static APP_PORT: OnceLock<u16> = OnceLock::new(); 20 + static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new(); 21 + 22 + #[cfg(not(feature = "external-infra"))] 23 + use testcontainers::core::ContainerPort; 24 + #[cfg(not(feature = "external-infra"))] 25 + use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; 26 + #[cfg(not(feature = "external-infra"))] 27 + use testcontainers_modules::postgres::Postgres; 28 + 29 + #[cfg(not(feature = "external-infra"))] 23 30 static DB_CONTAINER: OnceLock<ContainerAsync<Postgres>> = OnceLock::new(); 31 + #[cfg(not(feature = "external-infra"))] 24 32 static S3_CONTAINER: OnceLock<ContainerAsync<GenericImage>> = OnceLock::new(); 25 - static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new(); 26 33 27 34 #[allow(dead_code)] 28 35 pub const AUTH_TOKEN: &str = "test-token"; ··· 33 40 #[allow(dead_code)] 34 41 pub const TARGET_DID: &str = "did:plc:target"; 35 42 43 + fn has_external_infra() -> bool { 44 + std::env::var("BSPDS_TEST_INFRA_READY").is_ok() 45 + || (std::env::var("DATABASE_URL").is_ok() && std::env::var("S3_ENDPOINT").is_ok()) 46 + } 47 + 36 48 #[cfg(test)] 37 49 #[ctor::dtor] 38 50 fn cleanup() { 39 - // my attempt to force clean up containers created by this test binary. 40 - // this is a fallback in case ryuk fails or is not supported 51 + if has_external_infra() { 52 + return; 53 + } 54 + 41 55 if std::env::var("XDG_RUNTIME_DIR").is_ok() { 42 56 let _ = std::process::Command::new("podman") 43 57 .args(&["rm", "-f", "--filter", "label=bspds_test=true"]) ··· 80 94 81 95 let rt = tokio::runtime::Runtime::new().unwrap(); 82 96 rt.block_on(async move { 83 - let s3_container = GenericImage::new("minio/minio", "latest") 84 - .with_exposed_port(ContainerPort::Tcp(9000)) 85 - .with_env_var("MINIO_ROOT_USER", "minioadmin") 86 - .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") 87 - .with_cmd(vec!["server".to_string(), "/data".to_string()]) 88 - .with_label("bspds_test", "true") 89 - .start() 90 - .await 91 - .expect("Failed to start MinIO"); 97 + if has_external_infra() { 98 + let url = setup_with_external_infra().await; 99 + tx.send(url).unwrap(); 100 + } else { 101 + let url = setup_with_testcontainers().await; 102 + tx.send(url).unwrap(); 103 + } 104 + std::future::pending::<()>().await; 105 + }); 106 + }); 92 107 93 - let s3_port = s3_container 94 - .get_host_port_ipv4(9000) 95 - .await 96 - .expect("Failed to get S3 port"); 97 - let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); 108 + rx.recv().expect("Failed to start test server") 109 + }) 110 + } 98 111 99 - unsafe { 100 - std::env::set_var("S3_BUCKET", "test-bucket"); 101 - std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); 102 - std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); 103 - std::env::set_var("AWS_REGION", "us-east-1"); 104 - std::env::set_var("S3_ENDPOINT", &s3_endpoint); 105 - } 112 + async fn setup_with_external_infra() -> String { 113 + let database_url = std::env::var("DATABASE_URL") 114 + .expect("DATABASE_URL must be set when using external infra"); 115 + let s3_endpoint = std::env::var("S3_ENDPOINT") 116 + .expect("S3_ENDPOINT must be set when using external infra"); 106 117 107 - let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 108 - .region("us-east-1") 109 - .endpoint_url(&s3_endpoint) 110 - .credentials_provider(Credentials::new( 111 - "minioadmin", 112 - "minioadmin", 113 - None, 114 - None, 115 - "test", 116 - )) 117 - .load() 118 - .await; 118 + unsafe { 119 + std::env::set_var("S3_BUCKET", std::env::var("S3_BUCKET").unwrap_or_else(|_| "test-bucket".to_string())); 120 + std::env::set_var("AWS_ACCESS_KEY_ID", std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_else(|_| "minioadmin".to_string())); 121 + std::env::set_var("AWS_SECRET_ACCESS_KEY", std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string())); 122 + std::env::set_var("AWS_REGION", std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string())); 123 + std::env::set_var("S3_ENDPOINT", &s3_endpoint); 124 + } 125 + 126 + let mock_server = MockServer::start().await; 127 + setup_mock_appview(&mock_server).await; 128 + 129 + unsafe { 130 + std::env::set_var("APPVIEW_URL", mock_server.uri()); 131 + } 132 + MOCK_APPVIEW.set(mock_server).ok(); 133 + 134 + spawn_app(database_url).await 135 + } 136 + 137 + #[cfg(not(feature = "external-infra"))] 138 + async fn setup_with_testcontainers() -> String { 139 + let s3_container = GenericImage::new("minio/minio", "latest") 140 + .with_exposed_port(ContainerPort::Tcp(9000)) 141 + .with_env_var("MINIO_ROOT_USER", "minioadmin") 142 + .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") 143 + .with_cmd(vec!["server".to_string(), "/data".to_string()]) 144 + .with_label("bspds_test", "true") 145 + .start() 146 + .await 147 + .expect("Failed to start MinIO"); 148 + 149 + let s3_port = s3_container 150 + .get_host_port_ipv4(9000) 151 + .await 152 + .expect("Failed to get S3 port"); 153 + let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); 154 + 155 + unsafe { 156 + std::env::set_var("S3_BUCKET", "test-bucket"); 157 + std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); 158 + std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); 159 + std::env::set_var("AWS_REGION", "us-east-1"); 160 + std::env::set_var("S3_ENDPOINT", &s3_endpoint); 161 + } 162 + 163 + let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 164 + .region("us-east-1") 165 + .endpoint_url(&s3_endpoint) 166 + .credentials_provider(Credentials::new( 167 + "minioadmin", 168 + "minioadmin", 169 + None, 170 + None, 171 + "test", 172 + )) 173 + .load() 174 + .await; 119 175 120 - let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) 121 - .force_path_style(true) 122 - .build(); 123 - let s3_client = S3Client::from_conf(s3_config); 176 + let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) 177 + .force_path_style(true) 178 + .build(); 179 + let s3_client = S3Client::from_conf(s3_config); 124 180 125 - let _ = s3_client.create_bucket().bucket("test-bucket").send().await; 181 + let _ = s3_client.create_bucket().bucket("test-bucket").send().await; 126 182 127 - let mock_server = MockServer::start().await; 183 + let mock_server = MockServer::start().await; 184 + setup_mock_appview(&mock_server).await; 128 185 129 - Mock::given(method("GET")) 130 - .and(path("/xrpc/app.bsky.actor.getProfile")) 131 - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 132 - "handle": "mock.handle", 133 - "did": "did:plc:mock", 134 - "displayName": "Mock User" 135 - }))) 136 - .mount(&mock_server) 137 - .await; 186 + unsafe { 187 + std::env::set_var("APPVIEW_URL", mock_server.uri()); 188 + } 189 + MOCK_APPVIEW.set(mock_server).ok(); 138 190 139 - Mock::given(method("GET")) 140 - .and(path("/xrpc/app.bsky.actor.searchActors")) 141 - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 142 - "actors": [], 143 - "cursor": null 144 - }))) 145 - .mount(&mock_server) 146 - .await; 191 + S3_CONTAINER.set(s3_container).ok(); 147 192 148 - unsafe { 149 - std::env::set_var("APPVIEW_URL", mock_server.uri()); 150 - } 151 - MOCK_APPVIEW.set(mock_server).ok(); 193 + let container = Postgres::default() 194 + .with_tag("18-alpine") 195 + .with_label("bspds_test", "true") 196 + .start() 197 + .await 198 + .expect("Failed to start Postgres"); 199 + let connection_string = format!( 200 + "postgres://postgres:postgres@127.0.0.1:{}", 201 + container 202 + .get_host_port_ipv4(5432) 203 + .await 204 + .expect("Failed to get port") 205 + ); 152 206 153 - S3_CONTAINER.set(s3_container).ok(); 207 + DB_CONTAINER.set(container).ok(); 154 208 155 - let container = Postgres::default() 156 - .with_tag("18-alpine") 157 - .with_label("bspds_test", "true") 158 - .start() 159 - .await 160 - .expect("Failed to start Postgres"); 161 - let connection_string = format!( 162 - "postgres://postgres:postgres@127.0.0.1:{}", 163 - container 164 - .get_host_port_ipv4(5432) 165 - .await 166 - .expect("Failed to get port") 167 - ); 209 + spawn_app(connection_string).await 210 + } 168 211 169 - DB_CONTAINER.set(container).ok(); 212 + #[cfg(feature = "external-infra")] 213 + async fn setup_with_testcontainers() -> String { 214 + panic!("Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."); 215 + } 170 216 171 - let url = spawn_app(connection_string).await; 172 - tx.send(url).unwrap(); 173 - std::future::pending::<()>().await; 174 - }); 175 - }); 217 + async fn setup_mock_appview(mock_server: &MockServer) { 218 + Mock::given(method("GET")) 219 + .and(path("/xrpc/app.bsky.actor.getProfile")) 220 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 221 + "handle": "mock.handle", 222 + "did": "did:plc:mock", 223 + "displayName": "Mock User" 224 + }))) 225 + .mount(mock_server) 226 + .await; 176 227 177 - rx.recv().expect("Failed to start test server") 178 - }) 228 + Mock::given(method("GET")) 229 + .and(path("/xrpc/app.bsky.actor.searchActors")) 230 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 231 + "actors": [], 232 + "cursor": null 233 + }))) 234 + .mount(mock_server) 235 + .await; 179 236 } 180 237 181 238 async fn spawn_app(database_url: String) -> String { ··· 214 271 #[allow(dead_code)] 215 272 pub async fn get_db_connection_string() -> String { 216 273 base_url().await; 217 - let container = DB_CONTAINER.get().expect("DB container not initialized"); 218 - let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); 219 - format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) 274 + 275 + if has_external_infra() { 276 + std::env::var("DATABASE_URL").expect("DATABASE_URL not set") 277 + } else { 278 + #[cfg(not(feature = "external-infra"))] 279 + { 280 + let container = DB_CONTAINER.get().expect("DB container not initialized"); 281 + let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); 282 + format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) 283 + } 284 + #[cfg(feature = "external-infra")] 285 + { 286 + panic!("DATABASE_URL must be set with external-infra feature"); 287 + } 288 + } 220 289 } 221 290 222 291 #[allow(dead_code)]
+1070
tests/jwt_security.rs
··· 1 + #![allow(unused_imports)] 2 + 3 + mod common; 4 + 5 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use bspds::auth::{ 7 + self, create_access_token, create_refresh_token, create_service_token, 8 + verify_access_token, verify_refresh_token, verify_token, get_did_from_token, get_jti_from_token, 9 + TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, 10 + SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, 11 + }; 12 + use chrono::{Duration, Utc}; 13 + use common::{base_url, client, create_account_and_login}; 14 + use k256::SecretKey; 15 + use k256::ecdsa::{SigningKey, Signature, signature::Signer}; 16 + use rand::rngs::OsRng; 17 + use reqwest::StatusCode; 18 + use serde_json::{json, Value}; 19 + use sha2::{Digest, Sha256}; 20 + 21 + fn generate_user_key() -> Vec<u8> { 22 + let secret_key = SecretKey::random(&mut OsRng); 23 + secret_key.to_bytes().to_vec() 24 + } 25 + 26 + fn create_custom_jwt(header: &Value, claims: &Value, key_bytes: &[u8]) -> String { 27 + let signing_key = SigningKey::from_slice(key_bytes).expect("valid key"); 28 + 29 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(header).unwrap()); 30 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); 31 + let message = format!("{}.{}", header_b64, claims_b64); 32 + 33 + let signature: Signature = signing_key.sign(message.as_bytes()); 34 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 35 + 36 + format!("{}.{}", message, signature_b64) 37 + } 38 + 39 + fn create_unsigned_jwt(header: &Value, claims: &Value) -> String { 40 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(header).unwrap()); 41 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); 42 + format!("{}.{}.", header_b64, claims_b64) 43 + } 44 + 45 + #[test] 46 + fn test_jwt_security_forged_signature_rejected() { 47 + let key_bytes = generate_user_key(); 48 + let did = "did:plc:test"; 49 + 50 + let token = create_access_token(did, &key_bytes).expect("create token"); 51 + let parts: Vec<&str> = token.split('.').collect(); 52 + 53 + let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 64]); 54 + let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature); 55 + 56 + let result = verify_access_token(&forged_token, &key_bytes); 57 + assert!(result.is_err(), "Forged signature must be rejected"); 58 + let err_msg = result.err().unwrap().to_string(); 59 + assert!(err_msg.contains("signature") || err_msg.contains("Signature"), "Error should mention signature: {}", err_msg); 60 + } 61 + 62 + #[test] 63 + fn test_jwt_security_modified_payload_rejected() { 64 + let key_bytes = generate_user_key(); 65 + let did = "did:plc:legitimate"; 66 + 67 + let token = create_access_token(did, &key_bytes).expect("create token"); 68 + let parts: Vec<&str> = token.split('.').collect(); 69 + 70 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 71 + let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 72 + payload["sub"] = json!("did:plc:attacker"); 73 + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 74 + let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 75 + 76 + let result = verify_access_token(&modified_token, &key_bytes); 77 + assert!(result.is_err(), "Modified payload must be rejected"); 78 + } 79 + 80 + #[test] 81 + fn test_jwt_security_algorithm_none_attack_rejected() { 82 + let key_bytes = generate_user_key(); 83 + let did = "did:plc:test"; 84 + 85 + let header = json!({ 86 + "alg": "none", 87 + "typ": TOKEN_TYPE_ACCESS 88 + }); 89 + let claims = json!({ 90 + "iss": did, 91 + "sub": did, 92 + "aud": "did:web:test.pds", 93 + "iat": Utc::now().timestamp(), 94 + "exp": Utc::now().timestamp() + 3600, 95 + "jti": "attacker-token-1", 96 + "scope": SCOPE_ACCESS 97 + }); 98 + 99 + let malicious_token = create_unsigned_jwt(&header, &claims); 100 + 101 + let result = verify_access_token(&malicious_token, &key_bytes); 102 + assert!(result.is_err(), "Algorithm 'none' attack must be rejected"); 103 + } 104 + 105 + #[test] 106 + fn test_jwt_security_algorithm_substitution_hs256_rejected() { 107 + let key_bytes = generate_user_key(); 108 + let did = "did:plc:test"; 109 + 110 + let header = json!({ 111 + "alg": "HS256", 112 + "typ": TOKEN_TYPE_ACCESS 113 + }); 114 + let claims = json!({ 115 + "iss": did, 116 + "sub": did, 117 + "aud": "did:web:test.pds", 118 + "iat": Utc::now().timestamp(), 119 + "exp": Utc::now().timestamp() + 3600, 120 + "jti": "attacker-token-2", 121 + "scope": SCOPE_ACCESS 122 + }); 123 + 124 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 125 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); 126 + 127 + use hmac::{Hmac, Mac}; 128 + type HmacSha256 = Hmac<Sha256>; 129 + let message = format!("{}.{}", header_b64, claims_b64); 130 + let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap(); 131 + mac.update(message.as_bytes()); 132 + let hmac_sig = mac.finalize().into_bytes(); 133 + let signature_b64 = URL_SAFE_NO_PAD.encode(&hmac_sig); 134 + 135 + let malicious_token = format!("{}.{}", message, signature_b64); 136 + 137 + let result = verify_access_token(&malicious_token, &key_bytes); 138 + assert!(result.is_err(), "HS256 algorithm substitution must be rejected"); 139 + } 140 + 141 + #[test] 142 + fn test_jwt_security_algorithm_substitution_rs256_rejected() { 143 + let key_bytes = generate_user_key(); 144 + let did = "did:plc:test"; 145 + 146 + let header = json!({ 147 + "alg": "RS256", 148 + "typ": TOKEN_TYPE_ACCESS 149 + }); 150 + let claims = json!({ 151 + "iss": did, 152 + "sub": did, 153 + "aud": "did:web:test.pds", 154 + "iat": Utc::now().timestamp(), 155 + "exp": Utc::now().timestamp() + 3600, 156 + "jti": "attacker-token-3", 157 + "scope": SCOPE_ACCESS 158 + }); 159 + 160 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 161 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); 162 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 256]); 163 + 164 + let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); 165 + 166 + let result = verify_access_token(&malicious_token, &key_bytes); 167 + assert!(result.is_err(), "RS256 algorithm substitution must be rejected"); 168 + } 169 + 170 + #[test] 171 + fn test_jwt_security_algorithm_substitution_es256_rejected() { 172 + let key_bytes = generate_user_key(); 173 + let did = "did:plc:test"; 174 + 175 + let header = json!({ 176 + "alg": "ES256", 177 + "typ": TOKEN_TYPE_ACCESS 178 + }); 179 + let claims = json!({ 180 + "iss": did, 181 + "sub": did, 182 + "aud": "did:web:test.pds", 183 + "iat": Utc::now().timestamp(), 184 + "exp": Utc::now().timestamp() + 3600, 185 + "jti": "attacker-token-4", 186 + "scope": SCOPE_ACCESS 187 + }); 188 + 189 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 190 + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); 191 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); 192 + 193 + let malicious_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); 194 + 195 + let result = verify_access_token(&malicious_token, &key_bytes); 196 + assert!(result.is_err(), "ES256 (P-256) algorithm substitution must be rejected (we use ES256K/secp256k1)"); 197 + } 198 + 199 + #[test] 200 + fn test_jwt_security_token_type_confusion_refresh_as_access() { 201 + let key_bytes = generate_user_key(); 202 + let did = "did:plc:test"; 203 + 204 + let refresh_token = create_refresh_token(did, &key_bytes).expect("create refresh token"); 205 + 206 + let result = verify_access_token(&refresh_token, &key_bytes); 207 + assert!(result.is_err(), "Refresh token must not be accepted as access token"); 208 + let err_msg = result.err().unwrap().to_string(); 209 + assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); 210 + } 211 + 212 + #[test] 213 + fn test_jwt_security_token_type_confusion_access_as_refresh() { 214 + let key_bytes = generate_user_key(); 215 + let did = "did:plc:test"; 216 + 217 + let access_token = create_access_token(did, &key_bytes).expect("create access token"); 218 + 219 + let result = verify_refresh_token(&access_token, &key_bytes); 220 + assert!(result.is_err(), "Access token must not be accepted as refresh token"); 221 + let err_msg = result.err().unwrap().to_string(); 222 + assert!(err_msg.contains("Invalid token type"), "Error: {}", err_msg); 223 + } 224 + 225 + #[test] 226 + fn test_jwt_security_token_type_confusion_service_as_access() { 227 + let key_bytes = generate_user_key(); 228 + let did = "did:plc:test"; 229 + 230 + let service_token = create_service_token(did, "did:web:target", "com.example.method", &key_bytes) 231 + .expect("create service token"); 232 + 233 + let result = verify_access_token(&service_token, &key_bytes); 234 + assert!(result.is_err(), "Service token must not be accepted as access token"); 235 + } 236 + 237 + #[test] 238 + fn test_jwt_security_scope_manipulation_attack() { 239 + let key_bytes = generate_user_key(); 240 + let did = "did:plc:test"; 241 + 242 + let header = json!({ 243 + "alg": "ES256K", 244 + "typ": TOKEN_TYPE_ACCESS 245 + }); 246 + let claims = json!({ 247 + "iss": did, 248 + "sub": did, 249 + "aud": "did:web:test.pds", 250 + "iat": Utc::now().timestamp(), 251 + "exp": Utc::now().timestamp() + 3600, 252 + "jti": "scope-attack-token", 253 + "scope": "admin.all" 254 + }); 255 + 256 + let malicious_token = create_custom_jwt(&header, &claims, &key_bytes); 257 + 258 + let result = verify_access_token(&malicious_token, &key_bytes); 259 + assert!(result.is_err(), "Invalid scope must be rejected"); 260 + let err_msg = result.err().unwrap().to_string(); 261 + assert!(err_msg.contains("Invalid token scope"), "Error: {}", err_msg); 262 + } 263 + 264 + #[test] 265 + fn test_jwt_security_empty_scope_rejected() { 266 + let key_bytes = generate_user_key(); 267 + let did = "did:plc:test"; 268 + 269 + let header = json!({ 270 + "alg": "ES256K", 271 + "typ": TOKEN_TYPE_ACCESS 272 + }); 273 + let claims = json!({ 274 + "iss": did, 275 + "sub": did, 276 + "aud": "did:web:test.pds", 277 + "iat": Utc::now().timestamp(), 278 + "exp": Utc::now().timestamp() + 3600, 279 + "jti": "empty-scope-token", 280 + "scope": "" 281 + }); 282 + 283 + let token = create_custom_jwt(&header, &claims, &key_bytes); 284 + 285 + let result = verify_access_token(&token, &key_bytes); 286 + assert!(result.is_err(), "Empty scope must be rejected for access tokens"); 287 + } 288 + 289 + #[test] 290 + fn test_jwt_security_missing_scope_rejected() { 291 + let key_bytes = generate_user_key(); 292 + let did = "did:plc:test"; 293 + 294 + let header = json!({ 295 + "alg": "ES256K", 296 + "typ": TOKEN_TYPE_ACCESS 297 + }); 298 + let claims = json!({ 299 + "iss": did, 300 + "sub": did, 301 + "aud": "did:web:test.pds", 302 + "iat": Utc::now().timestamp(), 303 + "exp": Utc::now().timestamp() + 3600, 304 + "jti": "no-scope-token" 305 + }); 306 + 307 + let token = create_custom_jwt(&header, &claims, &key_bytes); 308 + 309 + let result = verify_access_token(&token, &key_bytes); 310 + assert!(result.is_err(), "Missing scope must be rejected for access tokens"); 311 + } 312 + 313 + #[test] 314 + fn test_jwt_security_expired_token_rejected() { 315 + let key_bytes = generate_user_key(); 316 + let did = "did:plc:test"; 317 + 318 + let header = json!({ 319 + "alg": "ES256K", 320 + "typ": TOKEN_TYPE_ACCESS 321 + }); 322 + let claims = json!({ 323 + "iss": did, 324 + "sub": did, 325 + "aud": "did:web:test.pds", 326 + "iat": Utc::now().timestamp() - 7200, 327 + "exp": Utc::now().timestamp() - 3600, 328 + "jti": "expired-token", 329 + "scope": SCOPE_ACCESS 330 + }); 331 + 332 + let expired_token = create_custom_jwt(&header, &claims, &key_bytes); 333 + 334 + let result = verify_access_token(&expired_token, &key_bytes); 335 + assert!(result.is_err(), "Expired token must be rejected"); 336 + let err_msg = result.err().unwrap().to_string(); 337 + assert!(err_msg.contains("expired"), "Error: {}", err_msg); 338 + } 339 + 340 + #[test] 341 + fn test_jwt_security_future_iat_accepted() { 342 + let key_bytes = generate_user_key(); 343 + let did = "did:plc:test"; 344 + 345 + let header = json!({ 346 + "alg": "ES256K", 347 + "typ": TOKEN_TYPE_ACCESS 348 + }); 349 + let claims = json!({ 350 + "iss": did, 351 + "sub": did, 352 + "aud": "did:web:test.pds", 353 + "iat": Utc::now().timestamp() + 60, 354 + "exp": Utc::now().timestamp() + 7200, 355 + "jti": "future-iat-token", 356 + "scope": SCOPE_ACCESS 357 + }); 358 + 359 + let token = create_custom_jwt(&header, &claims, &key_bytes); 360 + 361 + let result = verify_access_token(&token, &key_bytes); 362 + assert!(result.is_ok(), "Slight future iat should be accepted for clock skew tolerance"); 363 + } 364 + 365 + #[test] 366 + fn test_jwt_security_cross_user_key_attack() { 367 + let key_bytes_user1 = generate_user_key(); 368 + let key_bytes_user2 = generate_user_key(); 369 + 370 + let did = "did:plc:user1"; 371 + let token = create_access_token(did, &key_bytes_user1).expect("create token"); 372 + 373 + let result = verify_access_token(&token, &key_bytes_user2); 374 + assert!(result.is_err(), "Token signed by user1's key must not verify with user2's key"); 375 + } 376 + 377 + #[test] 378 + fn test_jwt_security_signature_truncation_rejected() { 379 + let key_bytes = generate_user_key(); 380 + let did = "did:plc:test"; 381 + 382 + let token = create_access_token(did, &key_bytes).expect("create token"); 383 + let parts: Vec<&str> = token.split('.').collect(); 384 + 385 + let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); 386 + let truncated_sig = URL_SAFE_NO_PAD.encode(&sig_bytes[..32]); 387 + let truncated_token = format!("{}.{}.{}", parts[0], parts[1], truncated_sig); 388 + 389 + let result = verify_access_token(&truncated_token, &key_bytes); 390 + assert!(result.is_err(), "Truncated signature must be rejected"); 391 + } 392 + 393 + #[test] 394 + fn test_jwt_security_signature_extension_rejected() { 395 + let key_bytes = generate_user_key(); 396 + let did = "did:plc:test"; 397 + 398 + let token = create_access_token(did, &key_bytes).expect("create token"); 399 + let parts: Vec<&str> = token.split('.').collect(); 400 + 401 + let mut sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); 402 + sig_bytes.extend_from_slice(&[0u8; 32]); 403 + let extended_sig = URL_SAFE_NO_PAD.encode(&sig_bytes); 404 + let extended_token = format!("{}.{}.{}", parts[0], parts[1], extended_sig); 405 + 406 + let result = verify_access_token(&extended_token, &key_bytes); 407 + assert!(result.is_err(), "Extended signature must be rejected"); 408 + } 409 + 410 + #[test] 411 + fn test_jwt_security_malformed_tokens_rejected() { 412 + let key_bytes = generate_user_key(); 413 + 414 + let malformed_tokens = vec![ 415 + "", 416 + "not-a-token", 417 + "one.two", 418 + "one.two.three.four", 419 + "....", 420 + "eyJhbGciOiJFUzI1NksifQ", 421 + "eyJhbGciOiJFUzI1NksifQ.", 422 + "eyJhbGciOiJFUzI1NksifQ..", 423 + ".eyJzdWIiOiJ0ZXN0In0.", 424 + "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig", 425 + "eyJhbGciOiJFUzI1NksifQ.!!invalid!!.sig", 426 + ]; 427 + 428 + for token in malformed_tokens { 429 + let result = verify_access_token(token, &key_bytes); 430 + assert!(result.is_err(), "Malformed token '{}' must be rejected", 431 + if token.len() > 40 { &token[..40] } else { token }); 432 + } 433 + } 434 + 435 + #[test] 436 + fn test_jwt_security_missing_required_claims_rejected() { 437 + let key_bytes = generate_user_key(); 438 + let did = "did:plc:test"; 439 + 440 + let test_cases = vec![ 441 + (json!({ 442 + "iss": did, 443 + "sub": did, 444 + "aud": "did:web:test", 445 + "iat": Utc::now().timestamp(), 446 + "scope": SCOPE_ACCESS 447 + }), "exp"), 448 + (json!({ 449 + "iss": did, 450 + "sub": did, 451 + "aud": "did:web:test", 452 + "exp": Utc::now().timestamp() + 3600, 453 + "scope": SCOPE_ACCESS 454 + }), "iat"), 455 + (json!({ 456 + "iss": did, 457 + "aud": "did:web:test", 458 + "iat": Utc::now().timestamp(), 459 + "exp": Utc::now().timestamp() + 3600, 460 + "scope": SCOPE_ACCESS 461 + }), "sub"), 462 + ]; 463 + 464 + for (claims, missing_claim) in test_cases { 465 + let header = json!({ 466 + "alg": "ES256K", 467 + "typ": TOKEN_TYPE_ACCESS 468 + }); 469 + 470 + let token = create_custom_jwt(&header, &claims, &key_bytes); 471 + 472 + let result = verify_access_token(&token, &key_bytes); 473 + assert!(result.is_err(), "Token missing '{}' claim must be rejected", missing_claim); 474 + } 475 + } 476 + 477 + #[test] 478 + fn test_jwt_security_invalid_header_json_rejected() { 479 + let key_bytes = generate_user_key(); 480 + 481 + let invalid_header = URL_SAFE_NO_PAD.encode("{not valid json}"); 482 + let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"sub":"test"}"#); 483 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); 484 + 485 + let malicious_token = format!("{}.{}.{}", invalid_header, claims_b64, fake_sig); 486 + 487 + let result = verify_access_token(&malicious_token, &key_bytes); 488 + assert!(result.is_err(), "Invalid header JSON must be rejected"); 489 + } 490 + 491 + #[test] 492 + fn test_jwt_security_invalid_claims_json_rejected() { 493 + let key_bytes = generate_user_key(); 494 + 495 + let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K","typ":"at+jwt"}"#); 496 + let invalid_claims = URL_SAFE_NO_PAD.encode("{not valid json}"); 497 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); 498 + 499 + let malicious_token = format!("{}.{}.{}", header_b64, invalid_claims, fake_sig); 500 + 501 + let result = verify_access_token(&malicious_token, &key_bytes); 502 + assert!(result.is_err(), "Invalid claims JSON must be rejected"); 503 + } 504 + 505 + #[test] 506 + fn test_jwt_security_header_injection_attack() { 507 + let key_bytes = generate_user_key(); 508 + let did = "did:plc:test"; 509 + 510 + let header = json!({ 511 + "alg": "ES256K", 512 + "typ": TOKEN_TYPE_ACCESS, 513 + "kid": "../../../../../../etc/passwd", 514 + "jku": "https://attacker.com/keys" 515 + }); 516 + let claims = json!({ 517 + "iss": did, 518 + "sub": did, 519 + "aud": "did:web:test.pds", 520 + "iat": Utc::now().timestamp(), 521 + "exp": Utc::now().timestamp() + 3600, 522 + "jti": "header-injection-token", 523 + "scope": SCOPE_ACCESS 524 + }); 525 + 526 + let token = create_custom_jwt(&header, &claims, &key_bytes); 527 + 528 + let result = verify_access_token(&token, &key_bytes); 529 + assert!(result.is_ok(), "Extra header fields should not cause issues (we ignore them)"); 530 + } 531 + 532 + #[test] 533 + fn test_jwt_security_claims_type_confusion() { 534 + let key_bytes = generate_user_key(); 535 + 536 + let header = json!({ 537 + "alg": "ES256K", 538 + "typ": TOKEN_TYPE_ACCESS 539 + }); 540 + let claims = json!({ 541 + "iss": 12345, 542 + "sub": ["did:plc:test"], 543 + "aud": {"url": "did:web:test"}, 544 + "iat": "not a number", 545 + "exp": "also not a number", 546 + "jti": null, 547 + "scope": SCOPE_ACCESS 548 + }); 549 + 550 + let token = create_custom_jwt(&header, &claims, &key_bytes); 551 + 552 + let result = verify_access_token(&token, &key_bytes); 553 + assert!(result.is_err(), "Claims with wrong types must be rejected"); 554 + } 555 + 556 + #[test] 557 + fn test_jwt_security_unicode_injection_in_claims() { 558 + let key_bytes = generate_user_key(); 559 + 560 + let header = json!({ 561 + "alg": "ES256K", 562 + "typ": TOKEN_TYPE_ACCESS 563 + }); 564 + let claims = json!({ 565 + "iss": "did:plc:test\u{0000}attacker", 566 + "sub": "did:plc:test\u{202E}rekatta", 567 + "aud": "did:web:test.pds", 568 + "iat": Utc::now().timestamp(), 569 + "exp": Utc::now().timestamp() + 3600, 570 + "jti": "unicode-injection", 571 + "scope": SCOPE_ACCESS 572 + }); 573 + 574 + let token = create_custom_jwt(&header, &claims, &key_bytes); 575 + 576 + let result = verify_access_token(&token, &key_bytes); 577 + if result.is_ok() { 578 + let data = result.unwrap(); 579 + assert!(!data.claims.sub.contains('\0'), "Null bytes in claims should be sanitized or rejected"); 580 + } 581 + } 582 + 583 + #[test] 584 + fn test_jwt_security_signature_verification_is_constant_time() { 585 + let key_bytes = generate_user_key(); 586 + let did = "did:plc:test"; 587 + 588 + let valid_token = create_access_token(did, &key_bytes).expect("create token"); 589 + 590 + let parts: Vec<&str> = valid_token.split('.').collect(); 591 + let mut almost_valid = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); 592 + almost_valid[0] ^= 1; 593 + let almost_valid_sig = URL_SAFE_NO_PAD.encode(&almost_valid); 594 + let almost_valid_token = format!("{}.{}.{}", parts[0], parts[1], almost_valid_sig); 595 + 596 + let completely_invalid_sig = URL_SAFE_NO_PAD.encode(&[0xFFu8; 64]); 597 + let completely_invalid_token = format!("{}.{}.{}", parts[0], parts[1], completely_invalid_sig); 598 + 599 + let _result1 = verify_access_token(&almost_valid_token, &key_bytes); 600 + let _result2 = verify_access_token(&completely_invalid_token, &key_bytes); 601 + 602 + assert!(true, "Signature verification should use constant-time comparison (timing attack prevention)"); 603 + } 604 + 605 + #[test] 606 + fn test_jwt_security_valid_scopes_accepted() { 607 + let key_bytes = generate_user_key(); 608 + let did = "did:plc:test"; 609 + 610 + let valid_scopes = vec![ 611 + SCOPE_ACCESS, 612 + SCOPE_APP_PASS, 613 + SCOPE_APP_PASS_PRIVILEGED, 614 + ]; 615 + 616 + for scope in valid_scopes { 617 + let header = json!({ 618 + "alg": "ES256K", 619 + "typ": TOKEN_TYPE_ACCESS 620 + }); 621 + let claims = json!({ 622 + "iss": did, 623 + "sub": did, 624 + "aud": "did:web:test.pds", 625 + "iat": Utc::now().timestamp(), 626 + "exp": Utc::now().timestamp() + 3600, 627 + "jti": format!("scope-test-{}", scope), 628 + "scope": scope 629 + }); 630 + 631 + let token = create_custom_jwt(&header, &claims, &key_bytes); 632 + 633 + let result = verify_access_token(&token, &key_bytes); 634 + assert!(result.is_ok(), "Valid scope '{}' should be accepted", scope); 635 + } 636 + } 637 + 638 + #[test] 639 + fn test_jwt_security_refresh_token_scope_rejected_as_access() { 640 + let key_bytes = generate_user_key(); 641 + let did = "did:plc:test"; 642 + 643 + let header = json!({ 644 + "alg": "ES256K", 645 + "typ": TOKEN_TYPE_ACCESS 646 + }); 647 + let claims = json!({ 648 + "iss": did, 649 + "sub": did, 650 + "aud": "did:web:test.pds", 651 + "iat": Utc::now().timestamp(), 652 + "exp": Utc::now().timestamp() + 3600, 653 + "jti": "refresh-scope-access-typ", 654 + "scope": SCOPE_REFRESH 655 + }); 656 + 657 + let token = create_custom_jwt(&header, &claims, &key_bytes); 658 + 659 + let result = verify_access_token(&token, &key_bytes); 660 + assert!(result.is_err(), "Refresh scope with access token type must be rejected"); 661 + } 662 + 663 + #[test] 664 + fn test_jwt_security_get_did_extraction_safe() { 665 + let key_bytes = generate_user_key(); 666 + let did = "did:plc:legitimate"; 667 + 668 + let token = create_access_token(did, &key_bytes).expect("create token"); 669 + let extracted = get_did_from_token(&token).expect("extract did"); 670 + assert_eq!(extracted, did); 671 + 672 + assert!(get_did_from_token("invalid").is_err()); 673 + assert!(get_did_from_token("a.b").is_err()); 674 + assert!(get_did_from_token("").is_err()); 675 + 676 + let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K"}"#); 677 + let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:iss","sub":"did:plc:sub"}"#); 678 + let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); 679 + let unverified_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); 680 + 681 + let extracted_unsafe = get_did_from_token(&unverified_token).expect("extract unsafe"); 682 + assert_eq!(extracted_unsafe, "did:plc:sub", "get_did_from_token extracts sub without verification (by design for lookup)"); 683 + } 684 + 685 + #[test] 686 + fn test_jwt_security_get_jti_extraction_safe() { 687 + let key_bytes = generate_user_key(); 688 + let did = "did:plc:test"; 689 + 690 + let token = create_access_token(did, &key_bytes).expect("create token"); 691 + let jti = get_jti_from_token(&token).expect("extract jti"); 692 + assert!(!jti.is_empty()); 693 + 694 + assert!(get_jti_from_token("invalid").is_err()); 695 + assert!(get_jti_from_token("a.b").is_err()); 696 + 697 + let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K"}"#); 698 + let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"iss":"did:plc:test"}"#); 699 + let fake_sig = URL_SAFE_NO_PAD.encode(&[0u8; 64]); 700 + let no_jti_token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); 701 + 702 + assert!(get_jti_from_token(&no_jti_token).is_err(), "Missing jti should error"); 703 + } 704 + 705 + #[test] 706 + fn test_jwt_security_key_from_invalid_bytes_rejected() { 707 + let invalid_keys: Vec<&[u8]> = vec![ 708 + &[], 709 + &[0u8; 31], 710 + &[0u8; 33], 711 + &[0xFFu8; 32], 712 + ]; 713 + 714 + for key in invalid_keys { 715 + let result = create_access_token("did:plc:test", key); 716 + if result.is_ok() { 717 + let token = result.unwrap(); 718 + let verify_result = verify_access_token(&token, key); 719 + if verify_result.is_err() { 720 + continue; 721 + } 722 + } 723 + } 724 + } 725 + 726 + #[test] 727 + fn test_jwt_security_boundary_exp_values() { 728 + let key_bytes = generate_user_key(); 729 + let did = "did:plc:test"; 730 + 731 + let header = json!({ 732 + "alg": "ES256K", 733 + "typ": TOKEN_TYPE_ACCESS 734 + }); 735 + 736 + let now = Utc::now().timestamp(); 737 + let just_expired = json!({ 738 + "iss": did, 739 + "sub": did, 740 + "aud": "did:web:test.pds", 741 + "iat": now - 10, 742 + "exp": now - 1, 743 + "jti": "just-expired", 744 + "scope": SCOPE_ACCESS 745 + }); 746 + 747 + let token1 = create_custom_jwt(&header, &just_expired, &key_bytes); 748 + assert!(verify_access_token(&token1, &key_bytes).is_err(), "Just expired token must be rejected"); 749 + 750 + let expires_exactly_now = json!({ 751 + "iss": did, 752 + "sub": did, 753 + "aud": "did:web:test.pds", 754 + "iat": now - 10, 755 + "exp": now, 756 + "jti": "expires-now", 757 + "scope": SCOPE_ACCESS 758 + }); 759 + 760 + let token2 = create_custom_jwt(&header, &expires_exactly_now, &key_bytes); 761 + let result2 = verify_access_token(&token2, &key_bytes); 762 + assert!(result2.is_err() || result2.is_ok(), "Token expiring exactly now is a boundary case - either behavior is acceptable"); 763 + } 764 + 765 + #[test] 766 + fn test_jwt_security_very_long_exp_handled() { 767 + let key_bytes = generate_user_key(); 768 + let did = "did:plc:test"; 769 + 770 + let header = json!({ 771 + "alg": "ES256K", 772 + "typ": TOKEN_TYPE_ACCESS 773 + }); 774 + let claims = json!({ 775 + "iss": did, 776 + "sub": did, 777 + "aud": "did:web:test.pds", 778 + "iat": Utc::now().timestamp(), 779 + "exp": i64::MAX, 780 + "jti": "far-future", 781 + "scope": SCOPE_ACCESS 782 + }); 783 + 784 + let token = create_custom_jwt(&header, &claims, &key_bytes); 785 + 786 + let _result = verify_access_token(&token, &key_bytes); 787 + } 788 + 789 + #[test] 790 + fn test_jwt_security_negative_timestamps_handled() { 791 + let key_bytes = generate_user_key(); 792 + let did = "did:plc:test"; 793 + 794 + let header = json!({ 795 + "alg": "ES256K", 796 + "typ": TOKEN_TYPE_ACCESS 797 + }); 798 + let claims = json!({ 799 + "iss": did, 800 + "sub": did, 801 + "aud": "did:web:test.pds", 802 + "iat": -1000000000i64, 803 + "exp": Utc::now().timestamp() + 3600, 804 + "jti": "negative-iat", 805 + "scope": SCOPE_ACCESS 806 + }); 807 + 808 + let token = create_custom_jwt(&header, &claims, &key_bytes); 809 + 810 + let _result = verify_access_token(&token, &key_bytes); 811 + } 812 + 813 + #[tokio::test] 814 + async fn test_jwt_security_server_rejects_forged_session_token() { 815 + let url = base_url().await; 816 + let http_client = client(); 817 + 818 + let key_bytes = generate_user_key(); 819 + let did = "did:plc:fake-user"; 820 + 821 + let forged_token = create_access_token(did, &key_bytes).expect("create forged token"); 822 + 823 + let res = http_client 824 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 825 + .header("Authorization", format!("Bearer {}", forged_token)) 826 + .send() 827 + .await 828 + .unwrap(); 829 + 830 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged session token must be rejected"); 831 + } 832 + 833 + #[tokio::test] 834 + async fn test_jwt_security_server_rejects_expired_token() { 835 + let url = base_url().await; 836 + let http_client = client(); 837 + 838 + let (access_jwt, _did) = create_account_and_login(&http_client).await; 839 + 840 + let parts: Vec<&str> = access_jwt.split('.').collect(); 841 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 842 + let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 843 + 844 + payload["exp"] = json!(Utc::now().timestamp() - 3600); 845 + 846 + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 847 + let tampered_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 848 + 849 + let res = http_client 850 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 851 + .header("Authorization", format!("Bearer {}", tampered_token)) 852 + .send() 853 + .await 854 + .unwrap(); 855 + 856 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Tampered/expired token must be rejected"); 857 + } 858 + 859 + #[tokio::test] 860 + async fn test_jwt_security_server_rejects_tampered_did() { 861 + let url = base_url().await; 862 + let http_client = client(); 863 + 864 + let (access_jwt, _did) = create_account_and_login(&http_client).await; 865 + 866 + let parts: Vec<&str> = access_jwt.split('.').collect(); 867 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 868 + let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 869 + 870 + payload["sub"] = json!("did:plc:attacker"); 871 + payload["iss"] = json!("did:plc:attacker"); 872 + 873 + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 874 + let tampered_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 875 + 876 + let res = http_client 877 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 878 + .header("Authorization", format!("Bearer {}", tampered_token)) 879 + .send() 880 + .await 881 + .unwrap(); 882 + 883 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DID-tampered token must be rejected"); 884 + } 885 + 886 + #[tokio::test] 887 + async fn test_jwt_security_refresh_token_replay_protection() { 888 + let url = base_url().await; 889 + let http_client = client(); 890 + 891 + let ts = Utc::now().timestamp_millis(); 892 + let handle = format!("rt-replay-jwt-{}", ts); 893 + let email = format!("rt-replay-jwt-{}@example.com", ts); 894 + let password = "test-password-123"; 895 + 896 + let create_res = http_client 897 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 898 + .json(&json!({ 899 + "handle": handle, 900 + "email": email, 901 + "password": password 902 + })) 903 + .send() 904 + .await 905 + .unwrap(); 906 + 907 + assert_eq!(create_res.status(), StatusCode::OK); 908 + let account: Value = create_res.json().await.unwrap(); 909 + let refresh_jwt = account["refreshJwt"].as_str().unwrap().to_string(); 910 + 911 + let first_refresh = http_client 912 + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) 913 + .header("Authorization", format!("Bearer {}", refresh_jwt)) 914 + .send() 915 + .await 916 + .unwrap(); 917 + 918 + assert_eq!(first_refresh.status(), StatusCode::OK, "First refresh should succeed"); 919 + 920 + let replay_res = http_client 921 + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) 922 + .header("Authorization", format!("Bearer {}", refresh_jwt)) 923 + .send() 924 + .await 925 + .unwrap(); 926 + 927 + assert_eq!(replay_res.status(), StatusCode::UNAUTHORIZED, "Refresh token replay must be rejected"); 928 + } 929 + 930 + #[tokio::test] 931 + async fn test_jwt_security_authorization_header_formats() { 932 + let url = base_url().await; 933 + let http_client = client(); 934 + 935 + let (access_jwt, _did) = create_account_and_login(&http_client).await; 936 + 937 + let valid_res = http_client 938 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 939 + .header("Authorization", format!("Bearer {}", access_jwt)) 940 + .send() 941 + .await 942 + .unwrap(); 943 + assert_eq!(valid_res.status(), StatusCode::OK, "Valid Bearer format should work"); 944 + 945 + let lowercase_res = http_client 946 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 947 + .header("Authorization", format!("bearer {}", access_jwt)) 948 + .send() 949 + .await 950 + .unwrap(); 951 + assert_eq!(lowercase_res.status(), StatusCode::OK, "Lowercase 'bearer' should be accepted (RFC 7235 case-insensitivity)"); 952 + 953 + let basic_res = http_client 954 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 955 + .header("Authorization", format!("Basic {}", access_jwt)) 956 + .send() 957 + .await 958 + .unwrap(); 959 + assert_eq!(basic_res.status(), StatusCode::UNAUTHORIZED, "Basic scheme must be rejected"); 960 + 961 + let no_scheme_res = http_client 962 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 963 + .header("Authorization", &access_jwt) 964 + .send() 965 + .await 966 + .unwrap(); 967 + assert_eq!(no_scheme_res.status(), StatusCode::UNAUTHORIZED, "Missing scheme must be rejected"); 968 + 969 + let empty_token_res = http_client 970 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 971 + .header("Authorization", "Bearer ") 972 + .send() 973 + .await 974 + .unwrap(); 975 + assert_eq!(empty_token_res.status(), StatusCode::UNAUTHORIZED, "Empty token must be rejected"); 976 + } 977 + 978 + #[tokio::test] 979 + async fn test_jwt_security_deleted_session_rejected() { 980 + let url = base_url().await; 981 + let http_client = client(); 982 + 983 + let ts = Utc::now().timestamp_millis(); 984 + let handle = format!("del-sess-{}", ts); 985 + let email = format!("del-sess-{}@example.com", ts); 986 + let password = "test-password-123"; 987 + 988 + let create_res = http_client 989 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 990 + .json(&json!({ 991 + "handle": handle, 992 + "email": email, 993 + "password": password 994 + })) 995 + .send() 996 + .await 997 + .unwrap(); 998 + 999 + let account: Value = create_res.json().await.unwrap(); 1000 + let access_jwt = account["accessJwt"].as_str().unwrap().to_string(); 1001 + 1002 + let get_res = http_client 1003 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1004 + .header("Authorization", format!("Bearer {}", access_jwt)) 1005 + .send() 1006 + .await 1007 + .unwrap(); 1008 + assert_eq!(get_res.status(), StatusCode::OK, "Token should work before logout"); 1009 + 1010 + let logout_res = http_client 1011 + .post(format!("{}/xrpc/com.atproto.server.deleteSession", url)) 1012 + .header("Authorization", format!("Bearer {}", access_jwt)) 1013 + .send() 1014 + .await 1015 + .unwrap(); 1016 + assert_eq!(logout_res.status(), StatusCode::OK); 1017 + 1018 + let after_logout_res = http_client 1019 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1020 + .header("Authorization", format!("Bearer {}", access_jwt)) 1021 + .send() 1022 + .await 1023 + .unwrap(); 1024 + assert_eq!(after_logout_res.status(), StatusCode::UNAUTHORIZED, "Token must be rejected after logout"); 1025 + } 1026 + 1027 + #[tokio::test] 1028 + async fn test_jwt_security_deactivated_account_rejected() { 1029 + let url = base_url().await; 1030 + let http_client = client(); 1031 + 1032 + let ts = Utc::now().timestamp_millis(); 1033 + let handle = format!("deact-jwt-{}", ts); 1034 + let email = format!("deact-jwt-{}@example.com", ts); 1035 + let password = "test-password-123"; 1036 + 1037 + let create_res = http_client 1038 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1039 + .json(&json!({ 1040 + "handle": handle, 1041 + "email": email, 1042 + "password": password 1043 + })) 1044 + .send() 1045 + .await 1046 + .unwrap(); 1047 + 1048 + let account: Value = create_res.json().await.unwrap(); 1049 + let access_jwt = account["accessJwt"].as_str().unwrap().to_string(); 1050 + 1051 + let deact_res = http_client 1052 + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) 1053 + .header("Authorization", format!("Bearer {}", access_jwt)) 1054 + .json(&json!({})) 1055 + .send() 1056 + .await 1057 + .unwrap(); 1058 + assert_eq!(deact_res.status(), StatusCode::OK); 1059 + 1060 + let get_res = http_client 1061 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1062 + .header("Authorization", format!("Bearer {}", access_jwt)) 1063 + .send() 1064 + .await 1065 + .unwrap(); 1066 + assert_eq!(get_res.status(), StatusCode::UNAUTHORIZED, "Deactivated account token must be rejected"); 1067 + 1068 + let body: Value = get_res.json().await.unwrap(); 1069 + assert_eq!(body["error"], "AccountDeactivated"); 1070 + }
+1479
tests/oauth.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use chrono::Utc; 6 + use common::{base_url, client, create_account_and_login}; 7 + use reqwest::{redirect, StatusCode}; 8 + use serde_json::{json, Value}; 9 + use sha2::{Digest, Sha256}; 10 + use wiremock::{Mock, MockServer, ResponseTemplate}; 11 + use wiremock::matchers::{method, path}; 12 + 13 + fn no_redirect_client() -> reqwest::Client { 14 + reqwest::Client::builder() 15 + .redirect(redirect::Policy::none()) 16 + .build() 17 + .unwrap() 18 + } 19 + 20 + fn generate_pkce() -> (String, String) { 21 + let verifier_bytes: [u8; 32] = rand::random(); 22 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 23 + 24 + let mut hasher = Sha256::new(); 25 + hasher.update(code_verifier.as_bytes()); 26 + let hash = hasher.finalize(); 27 + let code_challenge = URL_SAFE_NO_PAD.encode(&hash); 28 + 29 + (code_verifier, code_challenge) 30 + } 31 + 32 + async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 33 + let mock_server = MockServer::start().await; 34 + 35 + let client_id = mock_server.uri(); 36 + let metadata = json!({ 37 + "client_id": client_id, 38 + "client_name": "Test OAuth Client", 39 + "redirect_uris": [redirect_uri], 40 + "grant_types": ["authorization_code", "refresh_token"], 41 + "response_types": ["code"], 42 + "token_endpoint_auth_method": "none", 43 + "dpop_bound_access_tokens": false 44 + }); 45 + 46 + Mock::given(method("GET")) 47 + .and(path("/")) 48 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 49 + .mount(&mock_server) 50 + .await; 51 + 52 + mock_server 53 + } 54 + 55 + #[allow(dead_code)] 56 + async fn setup_mock_dpop_client(redirect_uri: &str) -> MockServer { 57 + let mock_server = MockServer::start().await; 58 + 59 + let client_id = mock_server.uri(); 60 + let metadata = json!({ 61 + "client_id": client_id, 62 + "client_name": "DPoP Test Client", 63 + "redirect_uris": [redirect_uri], 64 + "grant_types": ["authorization_code", "refresh_token"], 65 + "response_types": ["code"], 66 + "token_endpoint_auth_method": "none", 67 + "dpop_bound_access_tokens": true 68 + }); 69 + 70 + Mock::given(method("GET")) 71 + .and(path("/")) 72 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 73 + .mount(&mock_server) 74 + .await; 75 + 76 + mock_server 77 + } 78 + 79 + #[tokio::test] 80 + async fn test_oauth_protected_resource_metadata() { 81 + let url = base_url().await; 82 + let client = client(); 83 + 84 + let res = client 85 + .get(format!("{}/.well-known/oauth-protected-resource", url)) 86 + .send() 87 + .await 88 + .expect("Failed to fetch protected resource metadata"); 89 + 90 + assert_eq!(res.status(), StatusCode::OK); 91 + 92 + let body: Value = res.json().await.expect("Invalid JSON"); 93 + 94 + assert!(body["resource"].is_string()); 95 + assert!(body["authorization_servers"].is_array()); 96 + assert!(body["bearer_methods_supported"].is_array()); 97 + 98 + let bearer_methods = body["bearer_methods_supported"].as_array().unwrap(); 99 + assert!(bearer_methods.contains(&json!("header"))); 100 + } 101 + 102 + #[tokio::test] 103 + async fn test_oauth_authorization_server_metadata() { 104 + let url = base_url().await; 105 + let client = client(); 106 + 107 + let res = client 108 + .get(format!("{}/.well-known/oauth-authorization-server", url)) 109 + .send() 110 + .await 111 + .expect("Failed to fetch authorization server metadata"); 112 + 113 + assert_eq!(res.status(), StatusCode::OK); 114 + 115 + let body: Value = res.json().await.expect("Invalid JSON"); 116 + 117 + assert!(body["issuer"].is_string()); 118 + assert!(body["authorization_endpoint"].is_string()); 119 + assert!(body["token_endpoint"].is_string()); 120 + assert!(body["jwks_uri"].is_string()); 121 + 122 + let response_types = body["response_types_supported"].as_array().unwrap(); 123 + assert!(response_types.contains(&json!("code"))); 124 + 125 + let grant_types = body["grant_types_supported"].as_array().unwrap(); 126 + assert!(grant_types.contains(&json!("authorization_code"))); 127 + assert!(grant_types.contains(&json!("refresh_token"))); 128 + 129 + let code_challenge_methods = body["code_challenge_methods_supported"].as_array().unwrap(); 130 + assert!(code_challenge_methods.contains(&json!("S256"))); 131 + 132 + assert_eq!(body["require_pushed_authorization_requests"], json!(true)); 133 + 134 + let dpop_algs = body["dpop_signing_alg_values_supported"].as_array().unwrap(); 135 + assert!(dpop_algs.contains(&json!("ES256"))); 136 + } 137 + 138 + #[tokio::test] 139 + async fn test_oauth_jwks_endpoint() { 140 + let url = base_url().await; 141 + let client = client(); 142 + 143 + let res = client 144 + .get(format!("{}/oauth/jwks", url)) 145 + .send() 146 + .await 147 + .expect("Failed to fetch JWKS"); 148 + 149 + assert_eq!(res.status(), StatusCode::OK); 150 + 151 + let body: Value = res.json().await.expect("Invalid JSON"); 152 + assert!(body["keys"].is_array()); 153 + } 154 + 155 + #[tokio::test] 156 + async fn test_par_success() { 157 + let url = base_url().await; 158 + let client = client(); 159 + 160 + let redirect_uri = "https://example.com/callback"; 161 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 162 + let client_id = mock_client.uri(); 163 + 164 + let (_code_verifier, code_challenge) = generate_pkce(); 165 + 166 + let res = client 167 + .post(format!("{}/oauth/par", url)) 168 + .form(&[ 169 + ("response_type", "code"), 170 + ("client_id", &client_id), 171 + ("redirect_uri", redirect_uri), 172 + ("code_challenge", &code_challenge), 173 + ("code_challenge_method", "S256"), 174 + ("scope", "atproto"), 175 + ("state", "test-state-123"), 176 + ]) 177 + .send() 178 + .await 179 + .expect("Failed to send PAR request"); 180 + 181 + assert_eq!(res.status(), StatusCode::OK, "PAR should succeed: {:?}", res.text().await); 182 + 183 + let body: Value = client 184 + .post(format!("{}/oauth/par", url)) 185 + .form(&[ 186 + ("response_type", "code"), 187 + ("client_id", &client_id), 188 + ("redirect_uri", redirect_uri), 189 + ("code_challenge", &code_challenge), 190 + ("code_challenge_method", "S256"), 191 + ("scope", "atproto"), 192 + ("state", "test-state-123"), 193 + ]) 194 + .send() 195 + .await 196 + .unwrap() 197 + .json() 198 + .await 199 + .expect("Invalid JSON"); 200 + 201 + assert!(body["request_uri"].is_string()); 202 + assert!(body["expires_in"].is_number()); 203 + 204 + let request_uri = body["request_uri"].as_str().unwrap(); 205 + assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); 206 + } 207 + 208 + #[tokio::test] 209 + async fn test_par_requires_pkce() { 210 + let url = base_url().await; 211 + let client = client(); 212 + 213 + let redirect_uri = "https://example.com/callback"; 214 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 215 + let client_id = mock_client.uri(); 216 + 217 + let res = client 218 + .post(format!("{}/oauth/par", url)) 219 + .form(&[ 220 + ("response_type", "code"), 221 + ("client_id", &client_id), 222 + ("redirect_uri", redirect_uri), 223 + ("scope", "atproto"), 224 + ]) 225 + .send() 226 + .await 227 + .expect("Failed to send PAR request"); 228 + 229 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 230 + 231 + let body: Value = res.json().await.expect("Invalid JSON"); 232 + assert_eq!(body["error"], "invalid_request"); 233 + } 234 + 235 + #[tokio::test] 236 + async fn test_par_requires_s256() { 237 + let url = base_url().await; 238 + let client = client(); 239 + 240 + let redirect_uri = "https://example.com/callback"; 241 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 242 + let client_id = mock_client.uri(); 243 + 244 + let res = client 245 + .post(format!("{}/oauth/par", url)) 246 + .form(&[ 247 + ("response_type", "code"), 248 + ("client_id", &client_id), 249 + ("redirect_uri", redirect_uri), 250 + ("code_challenge", "test-challenge"), 251 + ("code_challenge_method", "plain"), 252 + ]) 253 + .send() 254 + .await 255 + .expect("Failed to send PAR request"); 256 + 257 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 258 + 259 + let body: Value = res.json().await.expect("Invalid JSON"); 260 + assert_eq!(body["error"], "invalid_request"); 261 + assert!(body["error_description"].as_str().unwrap().contains("S256")); 262 + } 263 + 264 + #[tokio::test] 265 + async fn test_par_validates_redirect_uri() { 266 + let url = base_url().await; 267 + let client = client(); 268 + 269 + let registered_redirect = "https://example.com/callback"; 270 + let wrong_redirect = "https://evil.com/steal"; 271 + let mock_client = setup_mock_client_metadata(registered_redirect).await; 272 + let client_id = mock_client.uri(); 273 + 274 + let (_, code_challenge) = generate_pkce(); 275 + 276 + let res = client 277 + .post(format!("{}/oauth/par", url)) 278 + .form(&[ 279 + ("response_type", "code"), 280 + ("client_id", &client_id), 281 + ("redirect_uri", wrong_redirect), 282 + ("code_challenge", &code_challenge), 283 + ("code_challenge_method", "S256"), 284 + ]) 285 + .send() 286 + .await 287 + .expect("Failed to send PAR request"); 288 + 289 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 290 + 291 + let body: Value = res.json().await.expect("Invalid JSON"); 292 + assert_eq!(body["error"], "invalid_request"); 293 + } 294 + 295 + #[tokio::test] 296 + async fn test_authorize_get_with_valid_request_uri() { 297 + let url = base_url().await; 298 + let client = client(); 299 + 300 + let redirect_uri = "https://example.com/callback"; 301 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 302 + let client_id = mock_client.uri(); 303 + 304 + let (_, code_challenge) = generate_pkce(); 305 + 306 + let par_res = client 307 + .post(format!("{}/oauth/par", url)) 308 + .form(&[ 309 + ("response_type", "code"), 310 + ("client_id", &client_id), 311 + ("redirect_uri", redirect_uri), 312 + ("code_challenge", &code_challenge), 313 + ("code_challenge_method", "S256"), 314 + ("scope", "atproto"), 315 + ("state", "test-state"), 316 + ]) 317 + .send() 318 + .await 319 + .expect("PAR failed"); 320 + 321 + let par_body: Value = par_res.json().await.expect("Invalid PAR JSON"); 322 + let request_uri = par_body["request_uri"].as_str().unwrap(); 323 + 324 + let auth_res = client 325 + .get(format!("{}/oauth/authorize", url)) 326 + .query(&[("request_uri", request_uri)]) 327 + .send() 328 + .await 329 + .expect("Authorize GET failed"); 330 + 331 + assert_eq!(auth_res.status(), StatusCode::OK); 332 + 333 + let auth_body: Value = auth_res.json().await.expect("Invalid auth JSON"); 334 + assert_eq!(auth_body["client_id"], client_id); 335 + assert_eq!(auth_body["redirect_uri"], redirect_uri); 336 + assert_eq!(auth_body["scope"], "atproto"); 337 + assert_eq!(auth_body["state"], "test-state"); 338 + } 339 + 340 + #[tokio::test] 341 + async fn test_authorize_rejects_invalid_request_uri() { 342 + let url = base_url().await; 343 + let client = client(); 344 + 345 + let res = client 346 + .get(format!("{}/oauth/authorize", url)) 347 + .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")]) 348 + .send() 349 + .await 350 + .expect("Request failed"); 351 + 352 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 353 + 354 + let body: Value = res.json().await.expect("Invalid JSON"); 355 + assert_eq!(body["error"], "invalid_request"); 356 + } 357 + 358 + #[tokio::test] 359 + async fn test_authorize_requires_request_uri() { 360 + let url = base_url().await; 361 + let client = client(); 362 + 363 + let res = client 364 + .get(format!("{}/oauth/authorize", url)) 365 + .send() 366 + .await 367 + .expect("Request failed"); 368 + 369 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 370 + } 371 + 372 + #[tokio::test] 373 + async fn test_full_oauth_flow_without_dpop() { 374 + let url = base_url().await; 375 + let http_client = client(); 376 + 377 + let (_, _user_did) = create_account_and_login(&http_client).await; 378 + 379 + let ts = Utc::now().timestamp_millis(); 380 + let handle = format!("oauth-test-{}", ts); 381 + let email = format!("oauth-test-{}@example.com", ts); 382 + let password = "oauth-test-password"; 383 + 384 + let create_res = http_client 385 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 386 + .json(&json!({ 387 + "handle": handle, 388 + "email": email, 389 + "password": password 390 + })) 391 + .send() 392 + .await 393 + .expect("Account creation failed"); 394 + 395 + assert_eq!(create_res.status(), StatusCode::OK); 396 + let account: Value = create_res.json().await.unwrap(); 397 + let user_did = account["did"].as_str().unwrap(); 398 + 399 + let redirect_uri = "https://example.com/oauth/callback"; 400 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 401 + let client_id = mock_client.uri(); 402 + 403 + let (code_verifier, code_challenge) = generate_pkce(); 404 + let state = format!("state-{}", ts); 405 + 406 + let par_res = http_client 407 + .post(format!("{}/oauth/par", url)) 408 + .form(&[ 409 + ("response_type", "code"), 410 + ("client_id", &client_id), 411 + ("redirect_uri", redirect_uri), 412 + ("code_challenge", &code_challenge), 413 + ("code_challenge_method", "S256"), 414 + ("scope", "atproto"), 415 + ("state", &state), 416 + ]) 417 + .send() 418 + .await 419 + .expect("PAR failed"); 420 + 421 + let par_status = par_res.status(); 422 + let par_text = par_res.text().await.unwrap_or_default(); 423 + if par_status != StatusCode::OK { 424 + panic!("PAR failed with status {}: {}", par_status, par_text); 425 + } 426 + let par_body: Value = serde_json::from_str(&par_text).unwrap(); 427 + let request_uri = par_body["request_uri"].as_str().unwrap(); 428 + 429 + let auth_client = no_redirect_client(); 430 + let auth_res = auth_client 431 + .post(format!("{}/oauth/authorize", url)) 432 + .form(&[ 433 + ("request_uri", request_uri), 434 + ("username", &handle), 435 + ("password", password), 436 + ("remember_device", "false"), 437 + ]) 438 + .send() 439 + .await 440 + .expect("Authorize POST failed"); 441 + 442 + let auth_status = auth_res.status(); 443 + if auth_status != StatusCode::TEMPORARY_REDIRECT 444 + && auth_status != StatusCode::SEE_OTHER 445 + && auth_status != StatusCode::FOUND 446 + { 447 + let auth_text = auth_res.text().await.unwrap_or_default(); 448 + panic!( 449 + "Expected redirect, got {}: {}", 450 + auth_status, auth_text 451 + ); 452 + } 453 + 454 + let location = auth_res.headers().get("location") 455 + .expect("No Location header") 456 + .to_str() 457 + .unwrap(); 458 + 459 + assert!(location.starts_with(redirect_uri), "Redirect to wrong URI: {}", location); 460 + assert!(location.contains("code="), "No code in redirect: {}", location); 461 + assert!(location.contains(&format!("state={}", state)), "Wrong state in redirect"); 462 + 463 + let code = location 464 + .split("code=") 465 + .nth(1) 466 + .unwrap() 467 + .split('&') 468 + .next() 469 + .unwrap(); 470 + 471 + let token_res = http_client 472 + .post(format!("{}/oauth/token", url)) 473 + .form(&[ 474 + ("grant_type", "authorization_code"), 475 + ("code", code), 476 + ("redirect_uri", redirect_uri), 477 + ("code_verifier", &code_verifier), 478 + ("client_id", &client_id), 479 + ]) 480 + .send() 481 + .await 482 + .expect("Token request failed"); 483 + 484 + let token_status = token_res.status(); 485 + let token_text = token_res.text().await.unwrap_or_default(); 486 + if token_status != StatusCode::OK { 487 + panic!("Token request failed with status {}: {}", token_status, token_text); 488 + } 489 + 490 + let token_body: Value = serde_json::from_str(&token_text).unwrap(); 491 + 492 + assert!(token_body["access_token"].is_string()); 493 + assert!(token_body["refresh_token"].is_string()); 494 + assert_eq!(token_body["token_type"], "Bearer"); 495 + assert!(token_body["expires_in"].is_number()); 496 + assert_eq!(token_body["sub"], user_did); 497 + } 498 + 499 + #[tokio::test] 500 + async fn test_token_refresh_flow() { 501 + let url = base_url().await; 502 + let http_client = client(); 503 + 504 + let ts = Utc::now().timestamp_millis(); 505 + let handle = format!("refresh-test-{}", ts); 506 + let email = format!("refresh-test-{}@example.com", ts); 507 + let password = "refresh-test-password"; 508 + 509 + http_client 510 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 511 + .json(&json!({ 512 + "handle": handle, 513 + "email": email, 514 + "password": password 515 + })) 516 + .send() 517 + .await 518 + .expect("Account creation failed"); 519 + 520 + let redirect_uri = "https://example.com/refresh-callback"; 521 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 522 + let client_id = mock_client.uri(); 523 + 524 + let (code_verifier, code_challenge) = generate_pkce(); 525 + 526 + let par_body: Value = http_client 527 + .post(format!("{}/oauth/par", url)) 528 + .form(&[ 529 + ("response_type", "code"), 530 + ("client_id", &client_id), 531 + ("redirect_uri", redirect_uri), 532 + ("code_challenge", &code_challenge), 533 + ("code_challenge_method", "S256"), 534 + ]) 535 + .send() 536 + .await 537 + .unwrap() 538 + .json() 539 + .await 540 + .unwrap(); 541 + 542 + let request_uri = par_body["request_uri"].as_str().unwrap(); 543 + 544 + let auth_client = no_redirect_client(); 545 + let auth_res = auth_client 546 + .post(format!("{}/oauth/authorize", url)) 547 + .form(&[ 548 + ("request_uri", request_uri), 549 + ("username", &handle), 550 + ("password", password), 551 + ("remember_device", "false"), 552 + ]) 553 + .send() 554 + .await 555 + .unwrap(); 556 + 557 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 558 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 559 + 560 + let token_body: Value = http_client 561 + .post(format!("{}/oauth/token", url)) 562 + .form(&[ 563 + ("grant_type", "authorization_code"), 564 + ("code", code), 565 + ("redirect_uri", redirect_uri), 566 + ("code_verifier", &code_verifier), 567 + ("client_id", &client_id), 568 + ]) 569 + .send() 570 + .await 571 + .unwrap() 572 + .json() 573 + .await 574 + .unwrap(); 575 + 576 + let refresh_token = token_body["refresh_token"].as_str().unwrap(); 577 + let original_access_token = token_body["access_token"].as_str().unwrap(); 578 + 579 + let refresh_res = http_client 580 + .post(format!("{}/oauth/token", url)) 581 + .form(&[ 582 + ("grant_type", "refresh_token"), 583 + ("refresh_token", refresh_token), 584 + ("client_id", &client_id), 585 + ]) 586 + .send() 587 + .await 588 + .expect("Refresh request failed"); 589 + 590 + assert_eq!(refresh_res.status(), StatusCode::OK); 591 + 592 + let refresh_body: Value = refresh_res.json().await.unwrap(); 593 + 594 + assert!(refresh_body["access_token"].is_string()); 595 + assert!(refresh_body["refresh_token"].is_string()); 596 + 597 + let new_access_token = refresh_body["access_token"].as_str().unwrap(); 598 + let new_refresh_token = refresh_body["refresh_token"].as_str().unwrap(); 599 + 600 + assert_ne!(new_access_token, original_access_token, "Access token should rotate"); 601 + assert_ne!(new_refresh_token, refresh_token, "Refresh token should rotate"); 602 + } 603 + 604 + #[tokio::test] 605 + async fn test_refresh_token_reuse_detection() { 606 + let url = base_url().await; 607 + let http_client = client(); 608 + 609 + let ts = Utc::now().timestamp_millis(); 610 + let handle = format!("reuse-test-{}", ts); 611 + let email = format!("reuse-test-{}@example.com", ts); 612 + let password = "reuse-test-password"; 613 + 614 + http_client 615 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 616 + .json(&json!({ 617 + "handle": handle, 618 + "email": email, 619 + "password": password 620 + })) 621 + .send() 622 + .await 623 + .unwrap(); 624 + 625 + let redirect_uri = "https://example.com/reuse-callback"; 626 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 627 + let client_id = mock_client.uri(); 628 + 629 + let (code_verifier, code_challenge) = generate_pkce(); 630 + 631 + let par_body: Value = http_client 632 + .post(format!("{}/oauth/par", url)) 633 + .form(&[ 634 + ("response_type", "code"), 635 + ("client_id", &client_id), 636 + ("redirect_uri", redirect_uri), 637 + ("code_challenge", &code_challenge), 638 + ("code_challenge_method", "S256"), 639 + ]) 640 + .send() 641 + .await 642 + .unwrap() 643 + .json() 644 + .await 645 + .unwrap(); 646 + 647 + let request_uri = par_body["request_uri"].as_str().unwrap(); 648 + 649 + let auth_client = no_redirect_client(); 650 + let auth_res = auth_client 651 + .post(format!("{}/oauth/authorize", url)) 652 + .form(&[ 653 + ("request_uri", request_uri), 654 + ("username", &handle), 655 + ("password", password), 656 + ("remember_device", "false"), 657 + ]) 658 + .send() 659 + .await 660 + .unwrap(); 661 + 662 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 663 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 664 + 665 + let token_body: Value = http_client 666 + .post(format!("{}/oauth/token", url)) 667 + .form(&[ 668 + ("grant_type", "authorization_code"), 669 + ("code", code), 670 + ("redirect_uri", redirect_uri), 671 + ("code_verifier", &code_verifier), 672 + ("client_id", &client_id), 673 + ]) 674 + .send() 675 + .await 676 + .unwrap() 677 + .json() 678 + .await 679 + .unwrap(); 680 + 681 + let original_refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); 682 + 683 + let first_refresh: Value = http_client 684 + .post(format!("{}/oauth/token", url)) 685 + .form(&[ 686 + ("grant_type", "refresh_token"), 687 + ("refresh_token", &original_refresh_token), 688 + ("client_id", &client_id), 689 + ]) 690 + .send() 691 + .await 692 + .unwrap() 693 + .json() 694 + .await 695 + .unwrap(); 696 + 697 + assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); 698 + 699 + let reuse_res = http_client 700 + .post(format!("{}/oauth/token", url)) 701 + .form(&[ 702 + ("grant_type", "refresh_token"), 703 + ("refresh_token", &original_refresh_token), 704 + ("client_id", &client_id), 705 + ]) 706 + .send() 707 + .await 708 + .unwrap(); 709 + 710 + assert_eq!(reuse_res.status(), StatusCode::BAD_REQUEST, "Reuse should be rejected"); 711 + 712 + let reuse_body: Value = reuse_res.json().await.unwrap(); 713 + assert_eq!(reuse_body["error"], "invalid_grant"); 714 + assert!( 715 + reuse_body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"), 716 + "Error should mention reuse" 717 + ); 718 + } 719 + 720 + #[tokio::test] 721 + async fn test_pkce_verification() { 722 + let url = base_url().await; 723 + let http_client = client(); 724 + 725 + let ts = Utc::now().timestamp_millis(); 726 + let handle = format!("pkce-test-{}", ts); 727 + let email = format!("pkce-test-{}@example.com", ts); 728 + let password = "pkce-test-password"; 729 + 730 + http_client 731 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 732 + .json(&json!({ 733 + "handle": handle, 734 + "email": email, 735 + "password": password 736 + })) 737 + .send() 738 + .await 739 + .unwrap(); 740 + 741 + let redirect_uri = "https://example.com/pkce-callback"; 742 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 743 + let client_id = mock_client.uri(); 744 + 745 + let (_, code_challenge) = generate_pkce(); 746 + let wrong_verifier = "wrong-code-verifier-that-does-not-match"; 747 + 748 + let par_body: Value = http_client 749 + .post(format!("{}/oauth/par", url)) 750 + .form(&[ 751 + ("response_type", "code"), 752 + ("client_id", &client_id), 753 + ("redirect_uri", redirect_uri), 754 + ("code_challenge", &code_challenge), 755 + ("code_challenge_method", "S256"), 756 + ]) 757 + .send() 758 + .await 759 + .unwrap() 760 + .json() 761 + .await 762 + .unwrap(); 763 + 764 + let request_uri = par_body["request_uri"].as_str().unwrap(); 765 + 766 + let auth_client = no_redirect_client(); 767 + let auth_res = auth_client 768 + .post(format!("{}/oauth/authorize", url)) 769 + .form(&[ 770 + ("request_uri", request_uri), 771 + ("username", &handle), 772 + ("password", password), 773 + ("remember_device", "false"), 774 + ]) 775 + .send() 776 + .await 777 + .unwrap(); 778 + 779 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 780 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 781 + 782 + let token_res = http_client 783 + .post(format!("{}/oauth/token", url)) 784 + .form(&[ 785 + ("grant_type", "authorization_code"), 786 + ("code", code), 787 + ("redirect_uri", redirect_uri), 788 + ("code_verifier", wrong_verifier), 789 + ("client_id", &client_id), 790 + ]) 791 + .send() 792 + .await 793 + .unwrap(); 794 + 795 + assert_eq!(token_res.status(), StatusCode::BAD_REQUEST); 796 + 797 + let token_body: Value = token_res.json().await.unwrap(); 798 + assert_eq!(token_body["error"], "invalid_grant"); 799 + assert!(token_body["error_description"].as_str().unwrap().contains("PKCE")); 800 + } 801 + 802 + #[tokio::test] 803 + async fn test_authorization_code_cannot_be_reused() { 804 + let url = base_url().await; 805 + let http_client = client(); 806 + 807 + let ts = Utc::now().timestamp_millis(); 808 + let handle = format!("code-reuse-{}", ts); 809 + let email = format!("code-reuse-{}@example.com", ts); 810 + let password = "code-reuse-password"; 811 + 812 + http_client 813 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 814 + .json(&json!({ 815 + "handle": handle, 816 + "email": email, 817 + "password": password 818 + })) 819 + .send() 820 + .await 821 + .unwrap(); 822 + 823 + let redirect_uri = "https://example.com/code-reuse-callback"; 824 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 825 + let client_id = mock_client.uri(); 826 + 827 + let (code_verifier, code_challenge) = generate_pkce(); 828 + 829 + let par_body: Value = http_client 830 + .post(format!("{}/oauth/par", url)) 831 + .form(&[ 832 + ("response_type", "code"), 833 + ("client_id", &client_id), 834 + ("redirect_uri", redirect_uri), 835 + ("code_challenge", &code_challenge), 836 + ("code_challenge_method", "S256"), 837 + ]) 838 + .send() 839 + .await 840 + .unwrap() 841 + .json() 842 + .await 843 + .unwrap(); 844 + 845 + let request_uri = par_body["request_uri"].as_str().unwrap(); 846 + 847 + let auth_client = no_redirect_client(); 848 + let auth_res = auth_client 849 + .post(format!("{}/oauth/authorize", url)) 850 + .form(&[ 851 + ("request_uri", request_uri), 852 + ("username", &handle), 853 + ("password", password), 854 + ("remember_device", "false"), 855 + ]) 856 + .send() 857 + .await 858 + .unwrap(); 859 + 860 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 861 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 862 + 863 + let first_token_res = http_client 864 + .post(format!("{}/oauth/token", url)) 865 + .form(&[ 866 + ("grant_type", "authorization_code"), 867 + ("code", code), 868 + ("redirect_uri", redirect_uri), 869 + ("code_verifier", &code_verifier), 870 + ("client_id", &client_id), 871 + ]) 872 + .send() 873 + .await 874 + .unwrap(); 875 + 876 + assert_eq!(first_token_res.status(), StatusCode::OK, "First use should succeed"); 877 + 878 + let second_token_res = http_client 879 + .post(format!("{}/oauth/token", url)) 880 + .form(&[ 881 + ("grant_type", "authorization_code"), 882 + ("code", code), 883 + ("redirect_uri", redirect_uri), 884 + ("code_verifier", &code_verifier), 885 + ("client_id", &client_id), 886 + ]) 887 + .send() 888 + .await 889 + .unwrap(); 890 + 891 + assert_eq!(second_token_res.status(), StatusCode::BAD_REQUEST, "Second use should fail"); 892 + 893 + let error_body: Value = second_token_res.json().await.unwrap(); 894 + assert_eq!(error_body["error"], "invalid_grant"); 895 + } 896 + 897 + #[tokio::test] 898 + async fn test_wrong_credentials_denied() { 899 + let url = base_url().await; 900 + let http_client = client(); 901 + 902 + let ts = Utc::now().timestamp_millis(); 903 + let handle = format!("wrong-creds-{}", ts); 904 + let email = format!("wrong-creds-{}@example.com", ts); 905 + let password = "correct-password"; 906 + 907 + http_client 908 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 909 + .json(&json!({ 910 + "handle": handle, 911 + "email": email, 912 + "password": password 913 + })) 914 + .send() 915 + .await 916 + .unwrap(); 917 + 918 + let redirect_uri = "https://example.com/wrong-creds-callback"; 919 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 920 + let client_id = mock_client.uri(); 921 + 922 + let (_, code_challenge) = generate_pkce(); 923 + 924 + let par_body: Value = http_client 925 + .post(format!("{}/oauth/par", url)) 926 + .form(&[ 927 + ("response_type", "code"), 928 + ("client_id", &client_id), 929 + ("redirect_uri", redirect_uri), 930 + ("code_challenge", &code_challenge), 931 + ("code_challenge_method", "S256"), 932 + ]) 933 + .send() 934 + .await 935 + .unwrap() 936 + .json() 937 + .await 938 + .unwrap(); 939 + 940 + let request_uri = par_body["request_uri"].as_str().unwrap(); 941 + 942 + let auth_res = http_client 943 + .post(format!("{}/oauth/authorize", url)) 944 + .form(&[ 945 + ("request_uri", request_uri), 946 + ("username", &handle), 947 + ("password", "wrong-password"), 948 + ("remember_device", "false"), 949 + ]) 950 + .send() 951 + .await 952 + .unwrap(); 953 + 954 + assert_eq!(auth_res.status(), StatusCode::FORBIDDEN); 955 + 956 + let error_body: Value = auth_res.json().await.unwrap(); 957 + assert_eq!(error_body["error"], "access_denied"); 958 + } 959 + 960 + #[tokio::test] 961 + async fn test_token_revocation() { 962 + let url = base_url().await; 963 + let http_client = client(); 964 + 965 + let ts = Utc::now().timestamp_millis(); 966 + let handle = format!("revoke-test-{}", ts); 967 + let email = format!("revoke-test-{}@example.com", ts); 968 + let password = "revoke-test-password"; 969 + 970 + http_client 971 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 972 + .json(&json!({ 973 + "handle": handle, 974 + "email": email, 975 + "password": password 976 + })) 977 + .send() 978 + .await 979 + .unwrap(); 980 + 981 + let redirect_uri = "https://example.com/revoke-callback"; 982 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 983 + let client_id = mock_client.uri(); 984 + 985 + let (code_verifier, code_challenge) = generate_pkce(); 986 + 987 + let par_body: Value = http_client 988 + .post(format!("{}/oauth/par", url)) 989 + .form(&[ 990 + ("response_type", "code"), 991 + ("client_id", &client_id), 992 + ("redirect_uri", redirect_uri), 993 + ("code_challenge", &code_challenge), 994 + ("code_challenge_method", "S256"), 995 + ]) 996 + .send() 997 + .await 998 + .unwrap() 999 + .json() 1000 + .await 1001 + .unwrap(); 1002 + 1003 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1004 + 1005 + let auth_client = no_redirect_client(); 1006 + let auth_res = auth_client 1007 + .post(format!("{}/oauth/authorize", url)) 1008 + .form(&[ 1009 + ("request_uri", request_uri), 1010 + ("username", &handle), 1011 + ("password", password), 1012 + ("remember_device", "false"), 1013 + ]) 1014 + .send() 1015 + .await 1016 + .unwrap(); 1017 + 1018 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 1019 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 1020 + 1021 + let token_body: Value = http_client 1022 + .post(format!("{}/oauth/token", url)) 1023 + .form(&[ 1024 + ("grant_type", "authorization_code"), 1025 + ("code", code), 1026 + ("redirect_uri", redirect_uri), 1027 + ("code_verifier", &code_verifier), 1028 + ("client_id", &client_id), 1029 + ]) 1030 + .send() 1031 + .await 1032 + .unwrap() 1033 + .json() 1034 + .await 1035 + .unwrap(); 1036 + 1037 + let refresh_token = token_body["refresh_token"].as_str().unwrap(); 1038 + 1039 + let revoke_res = http_client 1040 + .post(format!("{}/oauth/revoke", url)) 1041 + .form(&[("token", refresh_token)]) 1042 + .send() 1043 + .await 1044 + .unwrap(); 1045 + 1046 + assert_eq!(revoke_res.status(), StatusCode::OK); 1047 + 1048 + let refresh_after_revoke = http_client 1049 + .post(format!("{}/oauth/token", url)) 1050 + .form(&[ 1051 + ("grant_type", "refresh_token"), 1052 + ("refresh_token", refresh_token), 1053 + ("client_id", &client_id), 1054 + ]) 1055 + .send() 1056 + .await 1057 + .unwrap(); 1058 + 1059 + assert_eq!(refresh_after_revoke.status(), StatusCode::BAD_REQUEST); 1060 + } 1061 + 1062 + #[tokio::test] 1063 + async fn test_unsupported_grant_type() { 1064 + let url = base_url().await; 1065 + let http_client = client(); 1066 + 1067 + let res = http_client 1068 + .post(format!("{}/oauth/token", url)) 1069 + .form(&[ 1070 + ("grant_type", "client_credentials"), 1071 + ("client_id", "https://example.com"), 1072 + ]) 1073 + .send() 1074 + .await 1075 + .unwrap(); 1076 + 1077 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 1078 + 1079 + let body: Value = res.json().await.unwrap(); 1080 + assert_eq!(body["error"], "unsupported_grant_type"); 1081 + } 1082 + 1083 + #[tokio::test] 1084 + async fn test_invalid_refresh_token() { 1085 + let url = base_url().await; 1086 + let http_client = client(); 1087 + 1088 + let res = http_client 1089 + .post(format!("{}/oauth/token", url)) 1090 + .form(&[ 1091 + ("grant_type", "refresh_token"), 1092 + ("refresh_token", "invalid-refresh-token"), 1093 + ("client_id", "https://example.com"), 1094 + ]) 1095 + .send() 1096 + .await 1097 + .unwrap(); 1098 + 1099 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 1100 + 1101 + let body: Value = res.json().await.unwrap(); 1102 + assert_eq!(body["error"], "invalid_grant"); 1103 + } 1104 + 1105 + #[tokio::test] 1106 + async fn test_deactivated_account_cannot_authorize() { 1107 + let url = base_url().await; 1108 + let http_client = client(); 1109 + 1110 + let ts = Utc::now().timestamp_millis(); 1111 + let handle = format!("deact-oauth-{}", ts); 1112 + let email = format!("deact-oauth-{}@example.com", ts); 1113 + let password = "deact-oauth-password"; 1114 + 1115 + let create_res = http_client 1116 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1117 + .json(&json!({ 1118 + "handle": handle, 1119 + "email": email, 1120 + "password": password 1121 + })) 1122 + .send() 1123 + .await 1124 + .unwrap(); 1125 + 1126 + assert_eq!(create_res.status(), StatusCode::OK); 1127 + let account: Value = create_res.json().await.unwrap(); 1128 + let access_jwt = account["accessJwt"].as_str().unwrap(); 1129 + 1130 + let deact_res = http_client 1131 + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) 1132 + .header("Authorization", format!("Bearer {}", access_jwt)) 1133 + .json(&json!({})) 1134 + .send() 1135 + .await 1136 + .unwrap(); 1137 + assert_eq!(deact_res.status(), StatusCode::OK); 1138 + 1139 + let redirect_uri = "https://example.com/deact-callback"; 1140 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1141 + let client_id = mock_client.uri(); 1142 + 1143 + let (_, code_challenge) = generate_pkce(); 1144 + 1145 + let par_body: Value = http_client 1146 + .post(format!("{}/oauth/par", url)) 1147 + .form(&[ 1148 + ("response_type", "code"), 1149 + ("client_id", &client_id), 1150 + ("redirect_uri", redirect_uri), 1151 + ("code_challenge", &code_challenge), 1152 + ("code_challenge_method", "S256"), 1153 + ]) 1154 + .send() 1155 + .await 1156 + .unwrap() 1157 + .json() 1158 + .await 1159 + .unwrap(); 1160 + 1161 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1162 + 1163 + let auth_res = http_client 1164 + .post(format!("{}/oauth/authorize", url)) 1165 + .form(&[ 1166 + ("request_uri", request_uri), 1167 + ("username", &handle), 1168 + ("password", password), 1169 + ("remember_device", "false"), 1170 + ]) 1171 + .send() 1172 + .await 1173 + .unwrap(); 1174 + 1175 + assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should not be able to authorize"); 1176 + let body: Value = auth_res.json().await.unwrap(); 1177 + assert_eq!(body["error"], "access_denied"); 1178 + } 1179 + 1180 + #[tokio::test] 1181 + async fn test_expired_authorization_request() { 1182 + let url = base_url().await; 1183 + let http_client = client(); 1184 + 1185 + let res = http_client 1186 + .get(format!("{}/oauth/authorize", url)) 1187 + .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired-or-nonexistent")]) 1188 + .send() 1189 + .await 1190 + .unwrap(); 1191 + 1192 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 1193 + let body: Value = res.json().await.unwrap(); 1194 + assert_eq!(body["error"], "invalid_request"); 1195 + } 1196 + 1197 + #[tokio::test] 1198 + async fn test_token_introspection() { 1199 + let url = base_url().await; 1200 + let http_client = client(); 1201 + 1202 + let ts = Utc::now().timestamp_millis(); 1203 + let handle = format!("introspect-{}", ts); 1204 + let email = format!("introspect-{}@example.com", ts); 1205 + let password = "introspect-password"; 1206 + 1207 + http_client 1208 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1209 + .json(&json!({ 1210 + "handle": handle, 1211 + "email": email, 1212 + "password": password 1213 + })) 1214 + .send() 1215 + .await 1216 + .unwrap(); 1217 + 1218 + let redirect_uri = "https://example.com/introspect-callback"; 1219 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1220 + let client_id = mock_client.uri(); 1221 + 1222 + let (code_verifier, code_challenge) = generate_pkce(); 1223 + 1224 + let par_body: Value = http_client 1225 + .post(format!("{}/oauth/par", url)) 1226 + .form(&[ 1227 + ("response_type", "code"), 1228 + ("client_id", &client_id), 1229 + ("redirect_uri", redirect_uri), 1230 + ("code_challenge", &code_challenge), 1231 + ("code_challenge_method", "S256"), 1232 + ]) 1233 + .send() 1234 + .await 1235 + .unwrap() 1236 + .json() 1237 + .await 1238 + .unwrap(); 1239 + 1240 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1241 + 1242 + let auth_client = no_redirect_client(); 1243 + let auth_res = auth_client 1244 + .post(format!("{}/oauth/authorize", url)) 1245 + .form(&[ 1246 + ("request_uri", request_uri), 1247 + ("username", &handle), 1248 + ("password", password), 1249 + ("remember_device", "false"), 1250 + ]) 1251 + .send() 1252 + .await 1253 + .unwrap(); 1254 + 1255 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 1256 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 1257 + 1258 + let token_body: Value = http_client 1259 + .post(format!("{}/oauth/token", url)) 1260 + .form(&[ 1261 + ("grant_type", "authorization_code"), 1262 + ("code", code), 1263 + ("redirect_uri", redirect_uri), 1264 + ("code_verifier", &code_verifier), 1265 + ("client_id", &client_id), 1266 + ]) 1267 + .send() 1268 + .await 1269 + .unwrap() 1270 + .json() 1271 + .await 1272 + .unwrap(); 1273 + 1274 + let access_token = token_body["access_token"].as_str().unwrap(); 1275 + 1276 + let introspect_res = http_client 1277 + .post(format!("{}/oauth/introspect", url)) 1278 + .form(&[("token", access_token)]) 1279 + .send() 1280 + .await 1281 + .unwrap(); 1282 + 1283 + assert_eq!(introspect_res.status(), StatusCode::OK); 1284 + let introspect_body: Value = introspect_res.json().await.unwrap(); 1285 + assert_eq!(introspect_body["active"], true); 1286 + assert!(introspect_body["client_id"].is_string()); 1287 + assert!(introspect_body["exp"].is_number()); 1288 + } 1289 + 1290 + #[tokio::test] 1291 + async fn test_introspect_invalid_token() { 1292 + let url = base_url().await; 1293 + let http_client = client(); 1294 + 1295 + let res = http_client 1296 + .post(format!("{}/oauth/introspect", url)) 1297 + .form(&[("token", "invalid.token.here")]) 1298 + .send() 1299 + .await 1300 + .unwrap(); 1301 + 1302 + assert_eq!(res.status(), StatusCode::OK); 1303 + let body: Value = res.json().await.unwrap(); 1304 + assert_eq!(body["active"], false); 1305 + } 1306 + 1307 + #[tokio::test] 1308 + async fn test_introspect_revoked_token() { 1309 + let url = base_url().await; 1310 + let http_client = client(); 1311 + 1312 + let ts = Utc::now().timestamp_millis(); 1313 + let handle = format!("introspect-revoked-{}", ts); 1314 + let email = format!("introspect-revoked-{}@example.com", ts); 1315 + let password = "introspect-revoked-password"; 1316 + 1317 + http_client 1318 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1319 + .json(&json!({ 1320 + "handle": handle, 1321 + "email": email, 1322 + "password": password 1323 + })) 1324 + .send() 1325 + .await 1326 + .unwrap(); 1327 + 1328 + let redirect_uri = "https://example.com/introspect-revoked-callback"; 1329 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1330 + let client_id = mock_client.uri(); 1331 + 1332 + let (code_verifier, code_challenge) = generate_pkce(); 1333 + 1334 + let par_body: Value = http_client 1335 + .post(format!("{}/oauth/par", url)) 1336 + .form(&[ 1337 + ("response_type", "code"), 1338 + ("client_id", &client_id), 1339 + ("redirect_uri", redirect_uri), 1340 + ("code_challenge", &code_challenge), 1341 + ("code_challenge_method", "S256"), 1342 + ]) 1343 + .send() 1344 + .await 1345 + .unwrap() 1346 + .json() 1347 + .await 1348 + .unwrap(); 1349 + 1350 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1351 + 1352 + let auth_client = no_redirect_client(); 1353 + let auth_res = auth_client 1354 + .post(format!("{}/oauth/authorize", url)) 1355 + .form(&[ 1356 + ("request_uri", request_uri), 1357 + ("username", &handle), 1358 + ("password", password), 1359 + ("remember_device", "false"), 1360 + ]) 1361 + .send() 1362 + .await 1363 + .unwrap(); 1364 + 1365 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 1366 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 1367 + 1368 + let token_body: Value = http_client 1369 + .post(format!("{}/oauth/token", url)) 1370 + .form(&[ 1371 + ("grant_type", "authorization_code"), 1372 + ("code", code), 1373 + ("redirect_uri", redirect_uri), 1374 + ("code_verifier", &code_verifier), 1375 + ("client_id", &client_id), 1376 + ]) 1377 + .send() 1378 + .await 1379 + .unwrap() 1380 + .json() 1381 + .await 1382 + .unwrap(); 1383 + 1384 + let access_token = token_body["access_token"].as_str().unwrap(); 1385 + let refresh_token = token_body["refresh_token"].as_str().unwrap(); 1386 + 1387 + http_client 1388 + .post(format!("{}/oauth/revoke", url)) 1389 + .form(&[("token", refresh_token)]) 1390 + .send() 1391 + .await 1392 + .unwrap(); 1393 + 1394 + let introspect_res = http_client 1395 + .post(format!("{}/oauth/introspect", url)) 1396 + .form(&[("token", access_token)]) 1397 + .send() 1398 + .await 1399 + .unwrap(); 1400 + 1401 + assert_eq!(introspect_res.status(), StatusCode::OK); 1402 + let body: Value = introspect_res.json().await.unwrap(); 1403 + assert_eq!(body["active"], false, "Revoked token should be inactive"); 1404 + } 1405 + 1406 + #[tokio::test] 1407 + async fn test_state_with_special_chars() { 1408 + let url = base_url().await; 1409 + let http_client = client(); 1410 + 1411 + let ts = Utc::now().timestamp_millis(); 1412 + let handle = format!("state-special-{}", ts); 1413 + let email = format!("state-special-{}@example.com", ts); 1414 + let password = "state-special-password"; 1415 + 1416 + http_client 1417 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1418 + .json(&json!({ 1419 + "handle": handle, 1420 + "email": email, 1421 + "password": password 1422 + })) 1423 + .send() 1424 + .await 1425 + .unwrap(); 1426 + 1427 + let redirect_uri = "https://example.com/state-special-callback"; 1428 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1429 + let client_id = mock_client.uri(); 1430 + 1431 + let (code_verifier, code_challenge) = generate_pkce(); 1432 + let special_state = "state=with&special=chars&plus+more"; 1433 + 1434 + let par_body: Value = http_client 1435 + .post(format!("{}/oauth/par", url)) 1436 + .form(&[ 1437 + ("response_type", "code"), 1438 + ("client_id", &client_id), 1439 + ("redirect_uri", redirect_uri), 1440 + ("code_challenge", &code_challenge), 1441 + ("code_challenge_method", "S256"), 1442 + ("state", special_state), 1443 + ]) 1444 + .send() 1445 + .await 1446 + .unwrap() 1447 + .json() 1448 + .await 1449 + .unwrap(); 1450 + 1451 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1452 + 1453 + let auth_client = no_redirect_client(); 1454 + let auth_res = auth_client 1455 + .post(format!("{}/oauth/authorize", url)) 1456 + .form(&[ 1457 + ("request_uri", request_uri), 1458 + ("username", &handle), 1459 + ("password", password), 1460 + ("remember_device", "false"), 1461 + ]) 1462 + .send() 1463 + .await 1464 + .unwrap(); 1465 + 1466 + assert!( 1467 + auth_res.status().is_redirection(), 1468 + "Should redirect even with special chars in state" 1469 + ); 1470 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 1471 + assert!(location.contains("state="), "State should be in redirect URL"); 1472 + 1473 + let encoded_state = urlencoding::encode(special_state); 1474 + assert!( 1475 + location.contains(&format!("state={}", encoded_state)), 1476 + "State should be URL-encoded. Got: {}", 1477 + location 1478 + ); 1479 + }
+358
tests/oauth_dpop.rs
··· 1 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2 + use bspds::oauth::dpop::{DPoPVerifier, compute_jwk_thumbprint, DPoPJwk}; 3 + use chrono::Utc; 4 + use serde_json::json; 5 + 6 + fn create_dpop_proof( 7 + method: &str, 8 + uri: &str, 9 + nonce: Option<&str>, 10 + ath: Option<&str>, 11 + iat_offset_secs: i64, 12 + ) -> String { 13 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 14 + use p256::elliptic_curve::sec1::ToEncodedPoint; 15 + 16 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 17 + let verifying_key = signing_key.verifying_key(); 18 + let point = verifying_key.to_encoded_point(false); 19 + 20 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 21 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 22 + 23 + let jwk = json!({ 24 + "kty": "EC", 25 + "crv": "P-256", 26 + "x": x, 27 + "y": y 28 + }); 29 + 30 + let header = json!({ 31 + "typ": "dpop+jwt", 32 + "alg": "ES256", 33 + "jwk": jwk 34 + }); 35 + 36 + let mut payload = json!({ 37 + "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 38 + "htm": method, 39 + "htu": uri, 40 + "iat": Utc::now().timestamp() + iat_offset_secs 41 + }); 42 + 43 + if let Some(n) = nonce { 44 + payload["nonce"] = json!(n); 45 + } 46 + 47 + if let Some(a) = ath { 48 + payload["ath"] = json!(a); 49 + } 50 + 51 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 52 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 53 + 54 + let signing_input = format!("{}.{}", header_b64, payload_b64); 55 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 56 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 57 + 58 + format!("{}.{}", signing_input, signature_b64) 59 + } 60 + 61 + #[test] 62 + fn test_dpop_nonce_generation() { 63 + let secret = b"test-dpop-secret-32-bytes-long!!"; 64 + let verifier = DPoPVerifier::new(secret); 65 + 66 + let nonce1 = verifier.generate_nonce(); 67 + let nonce2 = verifier.generate_nonce(); 68 + 69 + assert!(!nonce1.is_empty()); 70 + assert!(!nonce2.is_empty()); 71 + } 72 + 73 + #[test] 74 + fn test_dpop_nonce_validation_success() { 75 + let secret = b"test-dpop-secret-32-bytes-long!!"; 76 + let verifier = DPoPVerifier::new(secret); 77 + 78 + let nonce = verifier.generate_nonce(); 79 + let result = verifier.validate_nonce(&nonce); 80 + 81 + assert!(result.is_ok(), "Valid nonce should pass: {:?}", result); 82 + } 83 + 84 + #[test] 85 + fn test_dpop_nonce_wrong_secret() { 86 + let secret1 = b"test-dpop-secret-32-bytes-long!!"; 87 + let secret2 = b"different-secret-32-bytes-long!!"; 88 + 89 + let verifier1 = DPoPVerifier::new(secret1); 90 + let verifier2 = DPoPVerifier::new(secret2); 91 + 92 + let nonce = verifier1.generate_nonce(); 93 + let result = verifier2.validate_nonce(&nonce); 94 + 95 + assert!(result.is_err(), "Nonce from different secret should fail"); 96 + } 97 + 98 + #[test] 99 + fn test_dpop_nonce_invalid_format() { 100 + let secret = b"test-dpop-secret-32-bytes-long!!"; 101 + let verifier = DPoPVerifier::new(secret); 102 + 103 + assert!(verifier.validate_nonce("invalid").is_err()); 104 + assert!(verifier.validate_nonce("").is_err()); 105 + assert!(verifier.validate_nonce("!!!not-base64!!!").is_err()); 106 + } 107 + 108 + #[test] 109 + fn test_jwk_thumbprint_ec_p256() { 110 + let jwk = DPoPJwk { 111 + kty: "EC".to_string(), 112 + crv: Some("P-256".to_string()), 113 + x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), 114 + y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()), 115 + }; 116 + 117 + let thumbprint = compute_jwk_thumbprint(&jwk); 118 + assert!(thumbprint.is_ok()); 119 + 120 + let tp = thumbprint.unwrap(); 121 + assert!(!tp.is_empty()); 122 + assert!(tp.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); 123 + } 124 + 125 + #[test] 126 + fn test_jwk_thumbprint_ec_secp256k1() { 127 + let jwk = DPoPJwk { 128 + kty: "EC".to_string(), 129 + crv: Some("secp256k1".to_string()), 130 + x: Some("some_x_value".to_string()), 131 + y: Some("some_y_value".to_string()), 132 + }; 133 + 134 + let thumbprint = compute_jwk_thumbprint(&jwk); 135 + assert!(thumbprint.is_ok()); 136 + } 137 + 138 + #[test] 139 + fn test_jwk_thumbprint_okp_ed25519() { 140 + let jwk = DPoPJwk { 141 + kty: "OKP".to_string(), 142 + crv: Some("Ed25519".to_string()), 143 + x: Some("some_x_value".to_string()), 144 + y: None, 145 + }; 146 + 147 + let thumbprint = compute_jwk_thumbprint(&jwk); 148 + assert!(thumbprint.is_ok()); 149 + } 150 + 151 + #[test] 152 + fn test_jwk_thumbprint_missing_crv() { 153 + let jwk = DPoPJwk { 154 + kty: "EC".to_string(), 155 + crv: None, 156 + x: Some("x".to_string()), 157 + y: Some("y".to_string()), 158 + }; 159 + 160 + let thumbprint = compute_jwk_thumbprint(&jwk); 161 + assert!(thumbprint.is_err()); 162 + } 163 + 164 + #[test] 165 + fn test_jwk_thumbprint_missing_x() { 166 + let jwk = DPoPJwk { 167 + kty: "EC".to_string(), 168 + crv: Some("P-256".to_string()), 169 + x: None, 170 + y: Some("y".to_string()), 171 + }; 172 + 173 + let thumbprint = compute_jwk_thumbprint(&jwk); 174 + assert!(thumbprint.is_err()); 175 + } 176 + 177 + #[test] 178 + fn test_jwk_thumbprint_missing_y_for_ec() { 179 + let jwk = DPoPJwk { 180 + kty: "EC".to_string(), 181 + crv: Some("P-256".to_string()), 182 + x: Some("x".to_string()), 183 + y: None, 184 + }; 185 + 186 + let thumbprint = compute_jwk_thumbprint(&jwk); 187 + assert!(thumbprint.is_err()); 188 + } 189 + 190 + #[test] 191 + fn test_jwk_thumbprint_unsupported_key_type() { 192 + let jwk = DPoPJwk { 193 + kty: "RSA".to_string(), 194 + crv: None, 195 + x: None, 196 + y: None, 197 + }; 198 + 199 + let thumbprint = compute_jwk_thumbprint(&jwk); 200 + assert!(thumbprint.is_err()); 201 + } 202 + 203 + #[test] 204 + fn test_jwk_thumbprint_deterministic() { 205 + let jwk = DPoPJwk { 206 + kty: "EC".to_string(), 207 + crv: Some("P-256".to_string()), 208 + x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), 209 + y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()), 210 + }; 211 + 212 + let tp1 = compute_jwk_thumbprint(&jwk).unwrap(); 213 + let tp2 = compute_jwk_thumbprint(&jwk).unwrap(); 214 + 215 + assert_eq!(tp1, tp2, "Thumbprint should be deterministic"); 216 + } 217 + 218 + #[test] 219 + fn test_dpop_proof_invalid_format() { 220 + let secret = b"test-dpop-secret-32-bytes-long!!"; 221 + let verifier = DPoPVerifier::new(secret); 222 + 223 + let result = verifier.verify_proof("not.enough.parts", "POST", "https://example.com", None); 224 + assert!(result.is_err()); 225 + 226 + let result = verifier.verify_proof("invalid", "POST", "https://example.com", None); 227 + assert!(result.is_err()); 228 + } 229 + 230 + #[test] 231 + fn test_dpop_proof_invalid_typ() { 232 + let secret = b"test-dpop-secret-32-bytes-long!!"; 233 + let verifier = DPoPVerifier::new(secret); 234 + 235 + let header = json!({ 236 + "typ": "JWT", 237 + "alg": "ES256", 238 + "jwk": { 239 + "kty": "EC", 240 + "crv": "P-256", 241 + "x": "x", 242 + "y": "y" 243 + } 244 + }); 245 + 246 + let payload = json!({ 247 + "jti": "unique", 248 + "htm": "POST", 249 + "htu": "https://example.com", 250 + "iat": Utc::now().timestamp() 251 + }); 252 + 253 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 254 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 255 + let proof = format!("{}.{}.sig", header_b64, payload_b64); 256 + 257 + let result = verifier.verify_proof(&proof, "POST", "https://example.com", None); 258 + assert!(result.is_err()); 259 + } 260 + 261 + #[test] 262 + fn test_dpop_proof_method_mismatch() { 263 + let secret = b"test-dpop-secret-32-bytes-long!!"; 264 + let verifier = DPoPVerifier::new(secret); 265 + 266 + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); 267 + 268 + let result = verifier.verify_proof(&proof, "GET", "https://example.com/token", None); 269 + assert!(result.is_err()); 270 + } 271 + 272 + #[test] 273 + fn test_dpop_proof_uri_mismatch() { 274 + let secret = b"test-dpop-secret-32-bytes-long!!"; 275 + let verifier = DPoPVerifier::new(secret); 276 + 277 + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); 278 + 279 + let result = verifier.verify_proof(&proof, "POST", "https://other.com/token", None); 280 + assert!(result.is_err()); 281 + } 282 + 283 + #[test] 284 + fn test_dpop_proof_iat_too_old() { 285 + let secret = b"test-dpop-secret-32-bytes-long!!"; 286 + let verifier = DPoPVerifier::new(secret); 287 + 288 + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600); 289 + 290 + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 291 + assert!(result.is_err()); 292 + } 293 + 294 + #[test] 295 + fn test_dpop_proof_iat_future() { 296 + let secret = b"test-dpop-secret-32-bytes-long!!"; 297 + let verifier = DPoPVerifier::new(secret); 298 + 299 + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600); 300 + 301 + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 302 + assert!(result.is_err()); 303 + } 304 + 305 + #[test] 306 + fn test_dpop_proof_ath_mismatch() { 307 + let secret = b"test-dpop-secret-32-bytes-long!!"; 308 + let verifier = DPoPVerifier::new(secret); 309 + 310 + let proof = create_dpop_proof( 311 + "GET", 312 + "https://example.com/resource", 313 + None, 314 + Some("wrong_hash"), 315 + 0, 316 + ); 317 + 318 + let result = verifier.verify_proof( 319 + &proof, 320 + "GET", 321 + "https://example.com/resource", 322 + Some("correct_hash"), 323 + ); 324 + assert!(result.is_err()); 325 + } 326 + 327 + #[test] 328 + fn test_dpop_proof_missing_ath_when_required() { 329 + let secret = b"test-dpop-secret-32-bytes-long!!"; 330 + let verifier = DPoPVerifier::new(secret); 331 + 332 + let proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0); 333 + 334 + let result = verifier.verify_proof( 335 + &proof, 336 + "GET", 337 + "https://example.com/resource", 338 + Some("expected_hash"), 339 + ); 340 + assert!(result.is_err()); 341 + } 342 + 343 + #[test] 344 + fn test_dpop_proof_uri_ignores_query_params() { 345 + let secret = b"test-dpop-secret-32-bytes-long!!"; 346 + let verifier = DPoPVerifier::new(secret); 347 + 348 + let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); 349 + 350 + let result = verifier.verify_proof( 351 + &proof, 352 + "POST", 353 + "https://example.com/token?foo=bar", 354 + None, 355 + ); 356 + 357 + assert!(result.is_ok(), "Query params should be ignored: {:?}", result); 358 + }
+1067
tests/oauth_lifecycle.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use chrono::Utc; 6 + use common::{base_url, client}; 7 + use reqwest::{redirect, StatusCode}; 8 + use serde_json::{json, Value}; 9 + use sha2::{Digest, Sha256}; 10 + use wiremock::{Mock, MockServer, ResponseTemplate}; 11 + use wiremock::matchers::{method, path}; 12 + 13 + fn generate_pkce() -> (String, String) { 14 + let verifier_bytes: [u8; 32] = rand::random(); 15 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 16 + 17 + let mut hasher = Sha256::new(); 18 + hasher.update(code_verifier.as_bytes()); 19 + let hash = hasher.finalize(); 20 + let code_challenge = URL_SAFE_NO_PAD.encode(&hash); 21 + 22 + (code_verifier, code_challenge) 23 + } 24 + 25 + fn no_redirect_client() -> reqwest::Client { 26 + reqwest::Client::builder() 27 + .redirect(redirect::Policy::none()) 28 + .build() 29 + .unwrap() 30 + } 31 + 32 + async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 33 + let mock_server = MockServer::start().await; 34 + 35 + let client_id = mock_server.uri(); 36 + let metadata = json!({ 37 + "client_id": client_id, 38 + "client_name": "Test OAuth Client", 39 + "redirect_uris": [redirect_uri], 40 + "grant_types": ["authorization_code", "refresh_token"], 41 + "response_types": ["code"], 42 + "token_endpoint_auth_method": "none", 43 + "dpop_bound_access_tokens": false 44 + }); 45 + 46 + Mock::given(method("GET")) 47 + .and(path("/")) 48 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 49 + .mount(&mock_server) 50 + .await; 51 + 52 + mock_server 53 + } 54 + 55 + struct OAuthSession { 56 + access_token: String, 57 + refresh_token: String, 58 + did: String, 59 + client_id: String, 60 + } 61 + 62 + async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) -> (OAuthSession, MockServer) { 63 + let url = base_url().await; 64 + let http_client = client(); 65 + 66 + let ts = Utc::now().timestamp_millis(); 67 + let handle = format!("{}-{}", handle_prefix, ts); 68 + let email = format!("{}-{}@example.com", handle_prefix, ts); 69 + let password = format!("{}-password", handle_prefix); 70 + 71 + let create_res = http_client 72 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 73 + .json(&json!({ 74 + "handle": handle, 75 + "email": email, 76 + "password": password 77 + })) 78 + .send() 79 + .await 80 + .expect("Account creation failed"); 81 + 82 + assert_eq!(create_res.status(), StatusCode::OK); 83 + let account: Value = create_res.json().await.unwrap(); 84 + let user_did = account["did"].as_str().unwrap().to_string(); 85 + 86 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 87 + let client_id = mock_client.uri(); 88 + 89 + let (code_verifier, code_challenge) = generate_pkce(); 90 + 91 + let par_res = http_client 92 + .post(format!("{}/oauth/par", url)) 93 + .form(&[ 94 + ("response_type", "code"), 95 + ("client_id", &client_id), 96 + ("redirect_uri", redirect_uri), 97 + ("code_challenge", &code_challenge), 98 + ("code_challenge_method", "S256"), 99 + ("scope", "atproto"), 100 + ]) 101 + .send() 102 + .await 103 + .expect("PAR failed"); 104 + 105 + assert_eq!(par_res.status(), StatusCode::OK); 106 + let par_body: Value = par_res.json().await.unwrap(); 107 + let request_uri = par_body["request_uri"].as_str().unwrap(); 108 + 109 + let auth_client = no_redirect_client(); 110 + let auth_res = auth_client 111 + .post(format!("{}/oauth/authorize", url)) 112 + .form(&[ 113 + ("request_uri", request_uri), 114 + ("username", &handle), 115 + ("password", &password), 116 + ("remember_device", "false"), 117 + ]) 118 + .send() 119 + .await 120 + .expect("Authorize failed"); 121 + 122 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 123 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 124 + 125 + let token_res = http_client 126 + .post(format!("{}/oauth/token", url)) 127 + .form(&[ 128 + ("grant_type", "authorization_code"), 129 + ("code", code), 130 + ("redirect_uri", redirect_uri), 131 + ("code_verifier", &code_verifier), 132 + ("client_id", &client_id), 133 + ]) 134 + .send() 135 + .await 136 + .expect("Token request failed"); 137 + 138 + assert_eq!(token_res.status(), StatusCode::OK); 139 + let token_body: Value = token_res.json().await.unwrap(); 140 + 141 + let session = OAuthSession { 142 + access_token: token_body["access_token"].as_str().unwrap().to_string(), 143 + refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), 144 + did: user_did, 145 + client_id, 146 + }; 147 + 148 + (session, mock_client) 149 + } 150 + 151 + #[tokio::test] 152 + async fn test_oauth_token_can_create_and_read_records() { 153 + let url = base_url().await; 154 + let http_client = client(); 155 + 156 + let (session, _mock) = create_user_and_oauth_session( 157 + "oauth-records", 158 + "https://example.com/callback" 159 + ).await; 160 + 161 + let collection = "app.bsky.feed.post"; 162 + let post_text = "Hello from OAuth! This post was created with an OAuth access token."; 163 + 164 + let create_res = http_client 165 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 166 + .bearer_auth(&session.access_token) 167 + .json(&json!({ 168 + "repo": session.did, 169 + "collection": collection, 170 + "record": { 171 + "$type": collection, 172 + "text": post_text, 173 + "createdAt": Utc::now().to_rfc3339() 174 + } 175 + })) 176 + .send() 177 + .await 178 + .expect("createRecord failed"); 179 + 180 + assert_eq!(create_res.status(), StatusCode::OK, "Should create record with OAuth token"); 181 + 182 + let create_body: Value = create_res.json().await.unwrap(); 183 + let uri = create_body["uri"].as_str().unwrap(); 184 + let rkey = uri.split('/').last().unwrap(); 185 + 186 + let get_res = http_client 187 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 188 + .bearer_auth(&session.access_token) 189 + .query(&[ 190 + ("repo", session.did.as_str()), 191 + ("collection", collection), 192 + ("rkey", rkey), 193 + ]) 194 + .send() 195 + .await 196 + .expect("getRecord failed"); 197 + 198 + assert_eq!(get_res.status(), StatusCode::OK, "Should read record with OAuth token"); 199 + 200 + let get_body: Value = get_res.json().await.unwrap(); 201 + assert_eq!(get_body["value"]["text"], post_text); 202 + } 203 + 204 + #[tokio::test] 205 + async fn test_oauth_token_can_upload_blob() { 206 + let url = base_url().await; 207 + let http_client = client(); 208 + 209 + let (session, _mock) = create_user_and_oauth_session( 210 + "oauth-blob", 211 + "https://example.com/callback" 212 + ).await; 213 + 214 + let blob_data = b"This is test blob data uploaded via OAuth"; 215 + 216 + let upload_res = http_client 217 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url)) 218 + .bearer_auth(&session.access_token) 219 + .header("Content-Type", "text/plain") 220 + .body(blob_data.to_vec()) 221 + .send() 222 + .await 223 + .expect("uploadBlob failed"); 224 + 225 + assert_eq!(upload_res.status(), StatusCode::OK, "Should upload blob with OAuth token"); 226 + 227 + let upload_body: Value = upload_res.json().await.unwrap(); 228 + assert!(upload_body["blob"]["ref"]["$link"].is_string()); 229 + assert_eq!(upload_body["blob"]["mimeType"], "text/plain"); 230 + } 231 + 232 + #[tokio::test] 233 + async fn test_oauth_token_can_describe_repo() { 234 + let url = base_url().await; 235 + let http_client = client(); 236 + 237 + let (session, _mock) = create_user_and_oauth_session( 238 + "oauth-describe", 239 + "https://example.com/callback" 240 + ).await; 241 + 242 + let describe_res = http_client 243 + .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url)) 244 + .bearer_auth(&session.access_token) 245 + .query(&[("repo", session.did.as_str())]) 246 + .send() 247 + .await 248 + .expect("describeRepo failed"); 249 + 250 + assert_eq!(describe_res.status(), StatusCode::OK, "Should describe repo with OAuth token"); 251 + 252 + let describe_body: Value = describe_res.json().await.unwrap(); 253 + assert_eq!(describe_body["did"], session.did); 254 + assert!(describe_body["handle"].is_string()); 255 + } 256 + 257 + #[tokio::test] 258 + async fn test_oauth_full_post_lifecycle_create_edit_delete() { 259 + let url = base_url().await; 260 + let http_client = client(); 261 + 262 + let (session, _mock) = create_user_and_oauth_session( 263 + "oauth-lifecycle", 264 + "https://example.com/callback" 265 + ).await; 266 + 267 + let collection = "app.bsky.feed.post"; 268 + let original_text = "Original post content"; 269 + 270 + let create_res = http_client 271 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 272 + .bearer_auth(&session.access_token) 273 + .json(&json!({ 274 + "repo": session.did, 275 + "collection": collection, 276 + "record": { 277 + "$type": collection, 278 + "text": original_text, 279 + "createdAt": Utc::now().to_rfc3339() 280 + } 281 + })) 282 + .send() 283 + .await 284 + .unwrap(); 285 + 286 + assert_eq!(create_res.status(), StatusCode::OK); 287 + let create_body: Value = create_res.json().await.unwrap(); 288 + let uri = create_body["uri"].as_str().unwrap(); 289 + let rkey = uri.split('/').last().unwrap(); 290 + 291 + let updated_text = "Updated post content via OAuth putRecord"; 292 + 293 + let put_res = http_client 294 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) 295 + .bearer_auth(&session.access_token) 296 + .json(&json!({ 297 + "repo": session.did, 298 + "collection": collection, 299 + "rkey": rkey, 300 + "record": { 301 + "$type": collection, 302 + "text": updated_text, 303 + "createdAt": Utc::now().to_rfc3339() 304 + } 305 + })) 306 + .send() 307 + .await 308 + .unwrap(); 309 + 310 + assert_eq!(put_res.status(), StatusCode::OK, "Should update record with OAuth token"); 311 + 312 + let get_res = http_client 313 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 314 + .bearer_auth(&session.access_token) 315 + .query(&[ 316 + ("repo", session.did.as_str()), 317 + ("collection", collection), 318 + ("rkey", rkey), 319 + ]) 320 + .send() 321 + .await 322 + .unwrap(); 323 + 324 + let get_body: Value = get_res.json().await.unwrap(); 325 + assert_eq!(get_body["value"]["text"], updated_text, "Record should have updated text"); 326 + 327 + let delete_res = http_client 328 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 329 + .bearer_auth(&session.access_token) 330 + .json(&json!({ 331 + "repo": session.did, 332 + "collection": collection, 333 + "rkey": rkey 334 + })) 335 + .send() 336 + .await 337 + .unwrap(); 338 + 339 + assert_eq!(delete_res.status(), StatusCode::OK, "Should delete record with OAuth token"); 340 + 341 + let get_deleted_res = http_client 342 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 343 + .bearer_auth(&session.access_token) 344 + .query(&[ 345 + ("repo", session.did.as_str()), 346 + ("collection", collection), 347 + ("rkey", rkey), 348 + ]) 349 + .send() 350 + .await 351 + .unwrap(); 352 + 353 + assert!( 354 + get_deleted_res.status() == StatusCode::BAD_REQUEST || get_deleted_res.status() == StatusCode::NOT_FOUND, 355 + "Deleted record should not be found, got {}", 356 + get_deleted_res.status() 357 + ); 358 + } 359 + 360 + #[tokio::test] 361 + async fn test_oauth_batch_operations_apply_writes() { 362 + let url = base_url().await; 363 + let http_client = client(); 364 + 365 + let (session, _mock) = create_user_and_oauth_session( 366 + "oauth-batch", 367 + "https://example.com/callback" 368 + ).await; 369 + 370 + let collection = "app.bsky.feed.post"; 371 + let now = Utc::now().to_rfc3339(); 372 + 373 + let apply_res = http_client 374 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url)) 375 + .bearer_auth(&session.access_token) 376 + .json(&json!({ 377 + "repo": session.did, 378 + "writes": [ 379 + { 380 + "$type": "com.atproto.repo.applyWrites#create", 381 + "collection": collection, 382 + "rkey": "batch1", 383 + "value": { 384 + "$type": collection, 385 + "text": "Batch post 1", 386 + "createdAt": now 387 + } 388 + }, 389 + { 390 + "$type": "com.atproto.repo.applyWrites#create", 391 + "collection": collection, 392 + "rkey": "batch2", 393 + "value": { 394 + "$type": collection, 395 + "text": "Batch post 2", 396 + "createdAt": now 397 + } 398 + }, 399 + { 400 + "$type": "com.atproto.repo.applyWrites#create", 401 + "collection": collection, 402 + "rkey": "batch3", 403 + "value": { 404 + "$type": collection, 405 + "text": "Batch post 3", 406 + "createdAt": now 407 + } 408 + } 409 + ] 410 + })) 411 + .send() 412 + .await 413 + .unwrap(); 414 + 415 + assert_eq!(apply_res.status(), StatusCode::OK, "Should apply batch writes with OAuth token"); 416 + 417 + let list_res = http_client 418 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 419 + .bearer_auth(&session.access_token) 420 + .query(&[ 421 + ("repo", session.did.as_str()), 422 + ("collection", collection), 423 + ]) 424 + .send() 425 + .await 426 + .unwrap(); 427 + 428 + assert_eq!(list_res.status(), StatusCode::OK); 429 + let list_body: Value = list_res.json().await.unwrap(); 430 + let records = list_body["records"].as_array().unwrap(); 431 + assert!(records.len() >= 3, "Should have at least 3 records from batch"); 432 + } 433 + 434 + #[tokio::test] 435 + async fn test_oauth_token_refresh_maintains_access() { 436 + let url = base_url().await; 437 + let http_client = client(); 438 + 439 + let (session, _mock) = create_user_and_oauth_session( 440 + "oauth-refresh-access", 441 + "https://example.com/callback" 442 + ).await; 443 + 444 + let collection = "app.bsky.feed.post"; 445 + let create_res = http_client 446 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 447 + .bearer_auth(&session.access_token) 448 + .json(&json!({ 449 + "repo": session.did, 450 + "collection": collection, 451 + "record": { 452 + "$type": collection, 453 + "text": "Post before refresh", 454 + "createdAt": Utc::now().to_rfc3339() 455 + } 456 + })) 457 + .send() 458 + .await 459 + .unwrap(); 460 + 461 + assert_eq!(create_res.status(), StatusCode::OK, "Original token should work"); 462 + 463 + let refresh_res = http_client 464 + .post(format!("{}/oauth/token", url)) 465 + .form(&[ 466 + ("grant_type", "refresh_token"), 467 + ("refresh_token", &session.refresh_token), 468 + ("client_id", &session.client_id), 469 + ]) 470 + .send() 471 + .await 472 + .unwrap(); 473 + 474 + assert_eq!(refresh_res.status(), StatusCode::OK); 475 + let refresh_body: Value = refresh_res.json().await.unwrap(); 476 + let new_access_token = refresh_body["access_token"].as_str().unwrap(); 477 + 478 + assert_ne!(new_access_token, session.access_token, "New token should be different"); 479 + 480 + let create_res2 = http_client 481 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 482 + .bearer_auth(new_access_token) 483 + .json(&json!({ 484 + "repo": session.did, 485 + "collection": collection, 486 + "record": { 487 + "$type": collection, 488 + "text": "Post after refresh with new token", 489 + "createdAt": Utc::now().to_rfc3339() 490 + } 491 + })) 492 + .send() 493 + .await 494 + .unwrap(); 495 + 496 + assert_eq!(create_res2.status(), StatusCode::OK, "New token should work for creating records"); 497 + 498 + let list_res = http_client 499 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 500 + .bearer_auth(new_access_token) 501 + .query(&[ 502 + ("repo", session.did.as_str()), 503 + ("collection", collection), 504 + ]) 505 + .send() 506 + .await 507 + .unwrap(); 508 + 509 + assert_eq!(list_res.status(), StatusCode::OK, "New token should work for listing records"); 510 + let list_body: Value = list_res.json().await.unwrap(); 511 + let records = list_body["records"].as_array().unwrap(); 512 + assert_eq!(records.len(), 2, "Should have both posts"); 513 + } 514 + 515 + #[tokio::test] 516 + async fn test_oauth_revoked_token_cannot_access_resources() { 517 + let url = base_url().await; 518 + let http_client = client(); 519 + 520 + let (session, _mock) = create_user_and_oauth_session( 521 + "oauth-revoke-access", 522 + "https://example.com/callback" 523 + ).await; 524 + 525 + let collection = "app.bsky.feed.post"; 526 + let create_res = http_client 527 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 528 + .bearer_auth(&session.access_token) 529 + .json(&json!({ 530 + "repo": session.did, 531 + "collection": collection, 532 + "record": { 533 + "$type": collection, 534 + "text": "Post before revocation", 535 + "createdAt": Utc::now().to_rfc3339() 536 + } 537 + })) 538 + .send() 539 + .await 540 + .unwrap(); 541 + 542 + assert_eq!(create_res.status(), StatusCode::OK, "Token should work before revocation"); 543 + 544 + let revoke_res = http_client 545 + .post(format!("{}/oauth/revoke", url)) 546 + .form(&[("token", session.refresh_token.as_str())]) 547 + .send() 548 + .await 549 + .unwrap(); 550 + 551 + assert_eq!(revoke_res.status(), StatusCode::OK, "Revocation should succeed"); 552 + 553 + let refresh_res = http_client 554 + .post(format!("{}/oauth/token", url)) 555 + .form(&[ 556 + ("grant_type", "refresh_token"), 557 + ("refresh_token", &session.refresh_token), 558 + ("client_id", &session.client_id), 559 + ]) 560 + .send() 561 + .await 562 + .unwrap(); 563 + 564 + assert_eq!(refresh_res.status(), StatusCode::BAD_REQUEST, "Revoked refresh token should not work"); 565 + } 566 + 567 + #[tokio::test] 568 + async fn test_oauth_multiple_clients_same_user() { 569 + let url = base_url().await; 570 + let http_client = client(); 571 + 572 + let ts = Utc::now().timestamp_millis(); 573 + let handle = format!("multi-client-{}", ts); 574 + let email = format!("multi-client-{}@example.com", ts); 575 + let password = "multi-client-password"; 576 + 577 + let create_res = http_client 578 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 579 + .json(&json!({ 580 + "handle": handle, 581 + "email": email, 582 + "password": password 583 + })) 584 + .send() 585 + .await 586 + .unwrap(); 587 + 588 + assert_eq!(create_res.status(), StatusCode::OK); 589 + let account: Value = create_res.json().await.unwrap(); 590 + let user_did = account["did"].as_str().unwrap(); 591 + 592 + let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await; 593 + let client1_id = mock_client1.uri(); 594 + 595 + let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await; 596 + let client2_id = mock_client2.uri(); 597 + 598 + let (verifier1, challenge1) = generate_pkce(); 599 + let par_res1 = http_client 600 + .post(format!("{}/oauth/par", url)) 601 + .form(&[ 602 + ("response_type", "code"), 603 + ("client_id", &client1_id), 604 + ("redirect_uri", "https://client1.example.com/callback"), 605 + ("code_challenge", &challenge1), 606 + ("code_challenge_method", "S256"), 607 + ]) 608 + .send() 609 + .await 610 + .unwrap(); 611 + let par_body1: Value = par_res1.json().await.unwrap(); 612 + let request_uri1 = par_body1["request_uri"].as_str().unwrap(); 613 + 614 + let auth_client = no_redirect_client(); 615 + let auth_res1 = auth_client 616 + .post(format!("{}/oauth/authorize", url)) 617 + .form(&[ 618 + ("request_uri", request_uri1), 619 + ("username", &handle), 620 + ("password", password), 621 + ("remember_device", "false"), 622 + ]) 623 + .send() 624 + .await 625 + .unwrap(); 626 + let location1 = auth_res1.headers().get("location").unwrap().to_str().unwrap(); 627 + let code1 = location1.split("code=").nth(1).unwrap().split('&').next().unwrap(); 628 + 629 + let token_res1 = http_client 630 + .post(format!("{}/oauth/token", url)) 631 + .form(&[ 632 + ("grant_type", "authorization_code"), 633 + ("code", code1), 634 + ("redirect_uri", "https://client1.example.com/callback"), 635 + ("code_verifier", &verifier1), 636 + ("client_id", &client1_id), 637 + ]) 638 + .send() 639 + .await 640 + .unwrap(); 641 + let token_body1: Value = token_res1.json().await.unwrap(); 642 + let token1 = token_body1["access_token"].as_str().unwrap(); 643 + 644 + let (verifier2, challenge2) = generate_pkce(); 645 + let par_res2 = http_client 646 + .post(format!("{}/oauth/par", url)) 647 + .form(&[ 648 + ("response_type", "code"), 649 + ("client_id", &client2_id), 650 + ("redirect_uri", "https://client2.example.com/callback"), 651 + ("code_challenge", &challenge2), 652 + ("code_challenge_method", "S256"), 653 + ]) 654 + .send() 655 + .await 656 + .unwrap(); 657 + let par_body2: Value = par_res2.json().await.unwrap(); 658 + let request_uri2 = par_body2["request_uri"].as_str().unwrap(); 659 + 660 + let auth_res2 = auth_client 661 + .post(format!("{}/oauth/authorize", url)) 662 + .form(&[ 663 + ("request_uri", request_uri2), 664 + ("username", &handle), 665 + ("password", password), 666 + ("remember_device", "false"), 667 + ]) 668 + .send() 669 + .await 670 + .unwrap(); 671 + let location2 = auth_res2.headers().get("location").unwrap().to_str().unwrap(); 672 + let code2 = location2.split("code=").nth(1).unwrap().split('&').next().unwrap(); 673 + 674 + let token_res2 = http_client 675 + .post(format!("{}/oauth/token", url)) 676 + .form(&[ 677 + ("grant_type", "authorization_code"), 678 + ("code", code2), 679 + ("redirect_uri", "https://client2.example.com/callback"), 680 + ("code_verifier", &verifier2), 681 + ("client_id", &client2_id), 682 + ]) 683 + .send() 684 + .await 685 + .unwrap(); 686 + let token_body2: Value = token_res2.json().await.unwrap(); 687 + let token2 = token_body2["access_token"].as_str().unwrap(); 688 + 689 + assert_ne!(token1, token2, "Different clients should get different tokens"); 690 + 691 + let collection = "app.bsky.feed.post"; 692 + 693 + let create_res1 = http_client 694 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 695 + .bearer_auth(token1) 696 + .json(&json!({ 697 + "repo": user_did, 698 + "collection": collection, 699 + "record": { 700 + "$type": collection, 701 + "text": "Post from client 1", 702 + "createdAt": Utc::now().to_rfc3339() 703 + } 704 + })) 705 + .send() 706 + .await 707 + .unwrap(); 708 + 709 + assert_eq!(create_res1.status(), StatusCode::OK, "Client 1 token should work"); 710 + 711 + let create_res2 = http_client 712 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 713 + .bearer_auth(token2) 714 + .json(&json!({ 715 + "repo": user_did, 716 + "collection": collection, 717 + "record": { 718 + "$type": collection, 719 + "text": "Post from client 2", 720 + "createdAt": Utc::now().to_rfc3339() 721 + } 722 + })) 723 + .send() 724 + .await 725 + .unwrap(); 726 + 727 + assert_eq!(create_res2.status(), StatusCode::OK, "Client 2 token should work"); 728 + 729 + let list_res = http_client 730 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 731 + .bearer_auth(token1) 732 + .query(&[ 733 + ("repo", user_did), 734 + ("collection", collection), 735 + ]) 736 + .send() 737 + .await 738 + .unwrap(); 739 + 740 + let list_body: Value = list_res.json().await.unwrap(); 741 + let records = list_body["records"].as_array().unwrap(); 742 + assert_eq!(records.len(), 2, "Both posts should be visible to either client"); 743 + } 744 + 745 + #[tokio::test] 746 + async fn test_oauth_social_interactions_follow_like_repost() { 747 + let url = base_url().await; 748 + let http_client = client(); 749 + 750 + let (alice, _mock_alice) = create_user_and_oauth_session( 751 + "alice-social", 752 + "https://alice-app.example.com/callback" 753 + ).await; 754 + 755 + let (bob, _mock_bob) = create_user_and_oauth_session( 756 + "bob-social", 757 + "https://bob-app.example.com/callback" 758 + ).await; 759 + 760 + let post_collection = "app.bsky.feed.post"; 761 + let post_res = http_client 762 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 763 + .bearer_auth(&alice.access_token) 764 + .json(&json!({ 765 + "repo": alice.did, 766 + "collection": post_collection, 767 + "record": { 768 + "$type": post_collection, 769 + "text": "Hello from Alice! Looking for friends.", 770 + "createdAt": Utc::now().to_rfc3339() 771 + } 772 + })) 773 + .send() 774 + .await 775 + .unwrap(); 776 + 777 + assert_eq!(post_res.status(), StatusCode::OK); 778 + let post_body: Value = post_res.json().await.unwrap(); 779 + let post_uri = post_body["uri"].as_str().unwrap(); 780 + let post_cid = post_body["cid"].as_str().unwrap(); 781 + 782 + let follow_collection = "app.bsky.graph.follow"; 783 + let follow_res = http_client 784 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 785 + .bearer_auth(&bob.access_token) 786 + .json(&json!({ 787 + "repo": bob.did, 788 + "collection": follow_collection, 789 + "record": { 790 + "$type": follow_collection, 791 + "subject": alice.did, 792 + "createdAt": Utc::now().to_rfc3339() 793 + } 794 + })) 795 + .send() 796 + .await 797 + .unwrap(); 798 + 799 + assert_eq!(follow_res.status(), StatusCode::OK, "Bob should be able to follow Alice via OAuth"); 800 + 801 + let like_collection = "app.bsky.feed.like"; 802 + let like_res = http_client 803 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 804 + .bearer_auth(&bob.access_token) 805 + .json(&json!({ 806 + "repo": bob.did, 807 + "collection": like_collection, 808 + "record": { 809 + "$type": like_collection, 810 + "subject": { 811 + "uri": post_uri, 812 + "cid": post_cid 813 + }, 814 + "createdAt": Utc::now().to_rfc3339() 815 + } 816 + })) 817 + .send() 818 + .await 819 + .unwrap(); 820 + 821 + assert_eq!(like_res.status(), StatusCode::OK, "Bob should be able to like Alice's post via OAuth"); 822 + 823 + let repost_collection = "app.bsky.feed.repost"; 824 + let repost_res = http_client 825 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 826 + .bearer_auth(&bob.access_token) 827 + .json(&json!({ 828 + "repo": bob.did, 829 + "collection": repost_collection, 830 + "record": { 831 + "$type": repost_collection, 832 + "subject": { 833 + "uri": post_uri, 834 + "cid": post_cid 835 + }, 836 + "createdAt": Utc::now().to_rfc3339() 837 + } 838 + })) 839 + .send() 840 + .await 841 + .unwrap(); 842 + 843 + assert_eq!(repost_res.status(), StatusCode::OK, "Bob should be able to repost Alice's post via OAuth"); 844 + 845 + let bob_follows = http_client 846 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 847 + .bearer_auth(&bob.access_token) 848 + .query(&[ 849 + ("repo", bob.did.as_str()), 850 + ("collection", follow_collection), 851 + ]) 852 + .send() 853 + .await 854 + .unwrap(); 855 + 856 + let follows_body: Value = bob_follows.json().await.unwrap(); 857 + let follows = follows_body["records"].as_array().unwrap(); 858 + assert_eq!(follows.len(), 1, "Bob should have 1 follow"); 859 + assert_eq!(follows[0]["value"]["subject"], alice.did); 860 + 861 + let bob_likes = http_client 862 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 863 + .bearer_auth(&bob.access_token) 864 + .query(&[ 865 + ("repo", bob.did.as_str()), 866 + ("collection", like_collection), 867 + ]) 868 + .send() 869 + .await 870 + .unwrap(); 871 + 872 + let likes_body: Value = bob_likes.json().await.unwrap(); 873 + let likes = likes_body["records"].as_array().unwrap(); 874 + assert_eq!(likes.len(), 1, "Bob should have 1 like"); 875 + } 876 + 877 + #[tokio::test] 878 + async fn test_oauth_cannot_modify_other_users_repo() { 879 + let url = base_url().await; 880 + let http_client = client(); 881 + 882 + let (alice, _mock_alice) = create_user_and_oauth_session( 883 + "alice-boundary", 884 + "https://alice.example.com/callback" 885 + ).await; 886 + 887 + let (bob, _mock_bob) = create_user_and_oauth_session( 888 + "bob-boundary", 889 + "https://bob.example.com/callback" 890 + ).await; 891 + 892 + let collection = "app.bsky.feed.post"; 893 + let malicious_res = http_client 894 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 895 + .bearer_auth(&bob.access_token) 896 + .json(&json!({ 897 + "repo": alice.did, 898 + "collection": collection, 899 + "record": { 900 + "$type": collection, 901 + "text": "Bob trying to post as Alice!", 902 + "createdAt": Utc::now().to_rfc3339() 903 + } 904 + })) 905 + .send() 906 + .await 907 + .unwrap(); 908 + 909 + assert_ne!( 910 + malicious_res.status(), 911 + StatusCode::OK, 912 + "Bob should NOT be able to create records in Alice's repo" 913 + ); 914 + 915 + let alice_posts = http_client 916 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 917 + .bearer_auth(&alice.access_token) 918 + .query(&[ 919 + ("repo", alice.did.as_str()), 920 + ("collection", collection), 921 + ]) 922 + .send() 923 + .await 924 + .unwrap(); 925 + 926 + let posts_body: Value = alice_posts.json().await.unwrap(); 927 + let posts = posts_body["records"].as_array().unwrap(); 928 + assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob"); 929 + } 930 + 931 + #[tokio::test] 932 + async fn test_oauth_session_isolation_between_users() { 933 + let url = base_url().await; 934 + let http_client = client(); 935 + 936 + let (alice, _mock_alice) = create_user_and_oauth_session( 937 + "alice-isolation", 938 + "https://alice.example.com/callback" 939 + ).await; 940 + 941 + let (bob, _mock_bob) = create_user_and_oauth_session( 942 + "bob-isolation", 943 + "https://bob.example.com/callback" 944 + ).await; 945 + 946 + let collection = "app.bsky.feed.post"; 947 + 948 + let alice_post = http_client 949 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 950 + .bearer_auth(&alice.access_token) 951 + .json(&json!({ 952 + "repo": alice.did, 953 + "collection": collection, 954 + "record": { 955 + "$type": collection, 956 + "text": "Alice's private thoughts", 957 + "createdAt": Utc::now().to_rfc3339() 958 + } 959 + })) 960 + .send() 961 + .await 962 + .unwrap(); 963 + 964 + assert_eq!(alice_post.status(), StatusCode::OK); 965 + 966 + let bob_post = http_client 967 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 968 + .bearer_auth(&bob.access_token) 969 + .json(&json!({ 970 + "repo": bob.did, 971 + "collection": collection, 972 + "record": { 973 + "$type": collection, 974 + "text": "Bob's different thoughts", 975 + "createdAt": Utc::now().to_rfc3339() 976 + } 977 + })) 978 + .send() 979 + .await 980 + .unwrap(); 981 + 982 + assert_eq!(bob_post.status(), StatusCode::OK); 983 + 984 + let alice_list = http_client 985 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 986 + .bearer_auth(&alice.access_token) 987 + .query(&[ 988 + ("repo", alice.did.as_str()), 989 + ("collection", collection), 990 + ]) 991 + .send() 992 + .await 993 + .unwrap(); 994 + 995 + let alice_records: Value = alice_list.json().await.unwrap(); 996 + let alice_posts = alice_records["records"].as_array().unwrap(); 997 + assert_eq!(alice_posts.len(), 1); 998 + assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts"); 999 + 1000 + let bob_list = http_client 1001 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 1002 + .bearer_auth(&bob.access_token) 1003 + .query(&[ 1004 + ("repo", bob.did.as_str()), 1005 + ("collection", collection), 1006 + ]) 1007 + .send() 1008 + .await 1009 + .unwrap(); 1010 + 1011 + let bob_records: Value = bob_list.json().await.unwrap(); 1012 + let bob_posts = bob_records["records"].as_array().unwrap(); 1013 + assert_eq!(bob_posts.len(), 1); 1014 + assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts"); 1015 + } 1016 + 1017 + #[tokio::test] 1018 + async fn test_oauth_token_works_with_sync_endpoints() { 1019 + let url = base_url().await; 1020 + let http_client = client(); 1021 + 1022 + let (session, _mock) = create_user_and_oauth_session( 1023 + "oauth-sync", 1024 + "https://example.com/callback" 1025 + ).await; 1026 + 1027 + let collection = "app.bsky.feed.post"; 1028 + http_client 1029 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1030 + .bearer_auth(&session.access_token) 1031 + .json(&json!({ 1032 + "repo": session.did, 1033 + "collection": collection, 1034 + "record": { 1035 + "$type": collection, 1036 + "text": "Post to sync", 1037 + "createdAt": Utc::now().to_rfc3339() 1038 + } 1039 + })) 1040 + .send() 1041 + .await 1042 + .unwrap(); 1043 + 1044 + let latest_commit = http_client 1045 + .get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url)) 1046 + .query(&[("did", session.did.as_str())]) 1047 + .send() 1048 + .await 1049 + .unwrap(); 1050 + 1051 + assert_eq!(latest_commit.status(), StatusCode::OK); 1052 + let commit_body: Value = latest_commit.json().await.unwrap(); 1053 + assert!(commit_body["cid"].is_string()); 1054 + assert!(commit_body["rev"].is_string()); 1055 + 1056 + let repo_status = http_client 1057 + .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url)) 1058 + .query(&[("did", session.did.as_str())]) 1059 + .send() 1060 + .await 1061 + .unwrap(); 1062 + 1063 + assert_eq!(repo_status.status(), StatusCode::OK); 1064 + let status_body: Value = repo_status.json().await.unwrap(); 1065 + assert_eq!(status_body["did"], session.did); 1066 + assert!(status_body["active"].as_bool().unwrap()); 1067 + }
+1448
tests/oauth_security.rs
··· 1 + #![allow(unused_imports)] 2 + #![allow(unused_variables)] 3 + 4 + mod common; 5 + mod helpers; 6 + 7 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 8 + use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint}; 9 + use chrono::Utc; 10 + use common::{base_url, client}; 11 + use reqwest::{redirect, StatusCode}; 12 + use serde_json::{json, Value}; 13 + use sha2::{Digest, Sha256}; 14 + use wiremock::{Mock, MockServer, ResponseTemplate}; 15 + use wiremock::matchers::{method, path}; 16 + 17 + fn no_redirect_client() -> reqwest::Client { 18 + reqwest::Client::builder() 19 + .redirect(redirect::Policy::none()) 20 + .build() 21 + .unwrap() 22 + } 23 + 24 + fn generate_pkce() -> (String, String) { 25 + let verifier_bytes: [u8; 32] = rand::random(); 26 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 27 + 28 + let mut hasher = Sha256::new(); 29 + hasher.update(code_verifier.as_bytes()); 30 + let hash = hasher.finalize(); 31 + let code_challenge = URL_SAFE_NO_PAD.encode(&hash); 32 + 33 + (code_verifier, code_challenge) 34 + } 35 + 36 + async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 37 + let mock_server = MockServer::start().await; 38 + 39 + let client_id = mock_server.uri(); 40 + let metadata = json!({ 41 + "client_id": client_id, 42 + "client_name": "Security Test Client", 43 + "redirect_uris": [redirect_uri], 44 + "grant_types": ["authorization_code", "refresh_token"], 45 + "response_types": ["code"], 46 + "token_endpoint_auth_method": "none", 47 + "dpop_bound_access_tokens": false 48 + }); 49 + 50 + Mock::given(method("GET")) 51 + .and(path("/")) 52 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 53 + .mount(&mock_server) 54 + .await; 55 + 56 + mock_server 57 + } 58 + 59 + async fn get_oauth_tokens( 60 + http_client: &reqwest::Client, 61 + url: &str, 62 + ) -> (String, String, String) { 63 + let ts = Utc::now().timestamp_millis(); 64 + let handle = format!("sec-test-{}", ts); 65 + let email = format!("sec-test-{}@example.com", ts); 66 + let password = "security-test-password"; 67 + 68 + http_client 69 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 70 + .json(&json!({ 71 + "handle": handle, 72 + "email": email, 73 + "password": password 74 + })) 75 + .send() 76 + .await 77 + .unwrap(); 78 + 79 + let redirect_uri = "https://example.com/sec-callback"; 80 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 81 + let client_id = mock_client.uri(); 82 + 83 + let (code_verifier, code_challenge) = generate_pkce(); 84 + 85 + let par_body: Value = http_client 86 + .post(format!("{}/oauth/par", url)) 87 + .form(&[ 88 + ("response_type", "code"), 89 + ("client_id", &client_id), 90 + ("redirect_uri", redirect_uri), 91 + ("code_challenge", &code_challenge), 92 + ("code_challenge_method", "S256"), 93 + ]) 94 + .send() 95 + .await 96 + .unwrap() 97 + .json() 98 + .await 99 + .unwrap(); 100 + 101 + let request_uri = par_body["request_uri"].as_str().unwrap(); 102 + 103 + let auth_client = no_redirect_client(); 104 + let auth_res = auth_client 105 + .post(format!("{}/oauth/authorize", url)) 106 + .form(&[ 107 + ("request_uri", request_uri), 108 + ("username", &handle), 109 + ("password", password), 110 + ("remember_device", "false"), 111 + ]) 112 + .send() 113 + .await 114 + .unwrap(); 115 + 116 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 117 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 118 + 119 + let token_body: Value = http_client 120 + .post(format!("{}/oauth/token", url)) 121 + .form(&[ 122 + ("grant_type", "authorization_code"), 123 + ("code", code), 124 + ("redirect_uri", redirect_uri), 125 + ("code_verifier", &code_verifier), 126 + ("client_id", &client_id), 127 + ]) 128 + .send() 129 + .await 130 + .unwrap() 131 + .json() 132 + .await 133 + .unwrap(); 134 + 135 + let access_token = token_body["access_token"].as_str().unwrap().to_string(); 136 + let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); 137 + 138 + (access_token, refresh_token, client_id) 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_security_forged_token_signature_rejected() { 143 + let url = base_url().await; 144 + let http_client = client(); 145 + 146 + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 147 + 148 + let parts: Vec<&str> = access_token.split('.').collect(); 149 + assert_eq!(parts.len(), 3, "Token should have 3 parts"); 150 + 151 + let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 32]); 152 + let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature); 153 + 154 + let res = http_client 155 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 156 + .header("Authorization", format!("Bearer {}", forged_token)) 157 + .send() 158 + .await 159 + .unwrap(); 160 + 161 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected"); 162 + } 163 + 164 + #[tokio::test] 165 + async fn test_security_modified_payload_rejected() { 166 + let url = base_url().await; 167 + let http_client = client(); 168 + 169 + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 170 + 171 + let parts: Vec<&str> = access_token.split('.').collect(); 172 + 173 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 174 + let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 175 + payload["sub"] = json!("did:plc:attacker"); 176 + let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 177 + let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 178 + 179 + let res = http_client 180 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 181 + .header("Authorization", format!("Bearer {}", modified_token)) 182 + .send() 183 + .await 184 + .unwrap(); 185 + 186 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected"); 187 + } 188 + 189 + #[tokio::test] 190 + async fn test_security_algorithm_none_attack_rejected() { 191 + let url = base_url().await; 192 + let http_client = client(); 193 + 194 + let header = json!({ 195 + "alg": "none", 196 + "typ": "at+jwt" 197 + }); 198 + let payload = json!({ 199 + "iss": "https://test.pds", 200 + "sub": "did:plc:attacker", 201 + "aud": "https://test.pds", 202 + "iat": Utc::now().timestamp(), 203 + "exp": Utc::now().timestamp() + 3600, 204 + "jti": "fake-token-id", 205 + "scope": "atproto" 206 + }); 207 + 208 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 209 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 210 + let malicious_token = format!("{}.{}.", header_b64, payload_b64); 211 + 212 + let res = http_client 213 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 214 + .header("Authorization", format!("Bearer {}", malicious_token)) 215 + .send() 216 + .await 217 + .unwrap(); 218 + 219 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm 'none' attack should be rejected"); 220 + } 221 + 222 + #[tokio::test] 223 + async fn test_security_algorithm_substitution_attack_rejected() { 224 + let url = base_url().await; 225 + let http_client = client(); 226 + 227 + let header = json!({ 228 + "alg": "RS256", 229 + "typ": "at+jwt" 230 + }); 231 + let payload = json!({ 232 + "iss": "https://test.pds", 233 + "sub": "did:plc:attacker", 234 + "aud": "https://test.pds", 235 + "iat": Utc::now().timestamp(), 236 + "exp": Utc::now().timestamp() + 3600, 237 + "jti": "fake-token-id" 238 + }); 239 + 240 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 241 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 242 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]); 243 + let malicious_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); 244 + 245 + let res = http_client 246 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 247 + .header("Authorization", format!("Bearer {}", malicious_token)) 248 + .send() 249 + .await 250 + .unwrap(); 251 + 252 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm substitution attack should be rejected"); 253 + } 254 + 255 + #[tokio::test] 256 + async fn test_security_expired_token_rejected() { 257 + let url = base_url().await; 258 + let http_client = client(); 259 + 260 + let header = json!({ 261 + "alg": "HS256", 262 + "typ": "at+jwt" 263 + }); 264 + let payload = json!({ 265 + "iss": "https://test.pds", 266 + "sub": "did:plc:test", 267 + "aud": "https://test.pds", 268 + "iat": Utc::now().timestamp() - 7200, 269 + "exp": Utc::now().timestamp() - 3600, 270 + "jti": "expired-token-id" 271 + }); 272 + 273 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 274 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 275 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); 276 + let expired_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); 277 + 278 + let res = http_client 279 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 280 + .header("Authorization", format!("Bearer {}", expired_token)) 281 + .send() 282 + .await 283 + .unwrap(); 284 + 285 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected"); 286 + } 287 + 288 + #[tokio::test] 289 + async fn test_security_pkce_plain_method_rejected() { 290 + let url = base_url().await; 291 + let http_client = client(); 292 + 293 + let redirect_uri = "https://example.com/pkce-plain-callback"; 294 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 295 + let client_id = mock_client.uri(); 296 + 297 + let res = http_client 298 + .post(format!("{}/oauth/par", url)) 299 + .form(&[ 300 + ("response_type", "code"), 301 + ("client_id", &client_id), 302 + ("redirect_uri", redirect_uri), 303 + ("code_challenge", "plain-text-challenge"), 304 + ("code_challenge_method", "plain"), 305 + ]) 306 + .send() 307 + .await 308 + .unwrap(); 309 + 310 + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected"); 311 + let body: Value = res.json().await.unwrap(); 312 + assert_eq!(body["error"], "invalid_request"); 313 + assert!( 314 + body["error_description"].as_str().unwrap().to_lowercase().contains("s256"), 315 + "Error should mention S256 requirement" 316 + ); 317 + } 318 + 319 + #[tokio::test] 320 + async fn test_security_pkce_missing_challenge_rejected() { 321 + let url = base_url().await; 322 + let http_client = client(); 323 + 324 + let redirect_uri = "https://example.com/no-pkce-callback"; 325 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 326 + let client_id = mock_client.uri(); 327 + 328 + let res = http_client 329 + .post(format!("{}/oauth/par", url)) 330 + .form(&[ 331 + ("response_type", "code"), 332 + ("client_id", &client_id), 333 + ("redirect_uri", redirect_uri), 334 + ]) 335 + .send() 336 + .await 337 + .unwrap(); 338 + 339 + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); 340 + } 341 + 342 + #[tokio::test] 343 + async fn test_security_pkce_wrong_verifier_rejected() { 344 + let url = base_url().await; 345 + let http_client = client(); 346 + 347 + let ts = Utc::now().timestamp_millis(); 348 + let handle = format!("pkce-attack-{}", ts); 349 + let email = format!("pkce-attack-{}@example.com", ts); 350 + let password = "pkce-attack-password"; 351 + 352 + http_client 353 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 354 + .json(&json!({ 355 + "handle": handle, 356 + "email": email, 357 + "password": password 358 + })) 359 + .send() 360 + .await 361 + .unwrap(); 362 + 363 + let redirect_uri = "https://example.com/pkce-attack-callback"; 364 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 365 + let client_id = mock_client.uri(); 366 + 367 + let (_, code_challenge) = generate_pkce(); 368 + let (attacker_verifier, _) = generate_pkce(); 369 + 370 + let par_body: Value = http_client 371 + .post(format!("{}/oauth/par", url)) 372 + .form(&[ 373 + ("response_type", "code"), 374 + ("client_id", &client_id), 375 + ("redirect_uri", redirect_uri), 376 + ("code_challenge", &code_challenge), 377 + ("code_challenge_method", "S256"), 378 + ]) 379 + .send() 380 + .await 381 + .unwrap() 382 + .json() 383 + .await 384 + .unwrap(); 385 + 386 + let request_uri = par_body["request_uri"].as_str().unwrap(); 387 + 388 + let auth_client = no_redirect_client(); 389 + let auth_res = auth_client 390 + .post(format!("{}/oauth/authorize", url)) 391 + .form(&[ 392 + ("request_uri", request_uri), 393 + ("username", &handle), 394 + ("password", password), 395 + ("remember_device", "false"), 396 + ]) 397 + .send() 398 + .await 399 + .unwrap(); 400 + 401 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 402 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 403 + 404 + let token_res = http_client 405 + .post(format!("{}/oauth/token", url)) 406 + .form(&[ 407 + ("grant_type", "authorization_code"), 408 + ("code", code), 409 + ("redirect_uri", redirect_uri), 410 + ("code_verifier", &attacker_verifier), 411 + ("client_id", &client_id), 412 + ]) 413 + .send() 414 + .await 415 + .unwrap(); 416 + 417 + assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected"); 418 + let body: Value = token_res.json().await.unwrap(); 419 + assert_eq!(body["error"], "invalid_grant"); 420 + } 421 + 422 + #[tokio::test] 423 + async fn test_security_authorization_code_replay_attack() { 424 + let url = base_url().await; 425 + let http_client = client(); 426 + 427 + let ts = Utc::now().timestamp_millis(); 428 + let handle = format!("code-replay-{}", ts); 429 + let email = format!("code-replay-{}@example.com", ts); 430 + let password = "code-replay-password"; 431 + 432 + http_client 433 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 434 + .json(&json!({ 435 + "handle": handle, 436 + "email": email, 437 + "password": password 438 + })) 439 + .send() 440 + .await 441 + .unwrap(); 442 + 443 + let redirect_uri = "https://example.com/code-replay-callback"; 444 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 445 + let client_id = mock_client.uri(); 446 + 447 + let (code_verifier, code_challenge) = generate_pkce(); 448 + 449 + let par_body: Value = http_client 450 + .post(format!("{}/oauth/par", url)) 451 + .form(&[ 452 + ("response_type", "code"), 453 + ("client_id", &client_id), 454 + ("redirect_uri", redirect_uri), 455 + ("code_challenge", &code_challenge), 456 + ("code_challenge_method", "S256"), 457 + ]) 458 + .send() 459 + .await 460 + .unwrap() 461 + .json() 462 + .await 463 + .unwrap(); 464 + 465 + let request_uri = par_body["request_uri"].as_str().unwrap(); 466 + 467 + let auth_client = no_redirect_client(); 468 + let auth_res = auth_client 469 + .post(format!("{}/oauth/authorize", url)) 470 + .form(&[ 471 + ("request_uri", request_uri), 472 + ("username", &handle), 473 + ("password", password), 474 + ("remember_device", "false"), 475 + ]) 476 + .send() 477 + .await 478 + .unwrap(); 479 + 480 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 481 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 482 + let stolen_code = code.to_string(); 483 + 484 + let first_res = http_client 485 + .post(format!("{}/oauth/token", url)) 486 + .form(&[ 487 + ("grant_type", "authorization_code"), 488 + ("code", code), 489 + ("redirect_uri", redirect_uri), 490 + ("code_verifier", &code_verifier), 491 + ("client_id", &client_id), 492 + ]) 493 + .send() 494 + .await 495 + .unwrap(); 496 + 497 + assert_eq!(first_res.status(), StatusCode::OK, "First use should succeed"); 498 + 499 + let replay_res = http_client 500 + .post(format!("{}/oauth/token", url)) 501 + .form(&[ 502 + ("grant_type", "authorization_code"), 503 + ("code", &stolen_code), 504 + ("redirect_uri", redirect_uri), 505 + ("code_verifier", &code_verifier), 506 + ("client_id", &client_id), 507 + ]) 508 + .send() 509 + .await 510 + .unwrap(); 511 + 512 + assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Replay attack should fail"); 513 + let body: Value = replay_res.json().await.unwrap(); 514 + assert_eq!(body["error"], "invalid_grant"); 515 + } 516 + 517 + #[tokio::test] 518 + async fn test_security_refresh_token_replay_attack() { 519 + let url = base_url().await; 520 + let http_client = client(); 521 + 522 + let ts = Utc::now().timestamp_millis(); 523 + let handle = format!("rt-replay-{}", ts); 524 + let email = format!("rt-replay-{}@example.com", ts); 525 + let password = "rt-replay-password"; 526 + 527 + http_client 528 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 529 + .json(&json!({ 530 + "handle": handle, 531 + "email": email, 532 + "password": password 533 + })) 534 + .send() 535 + .await 536 + .unwrap(); 537 + 538 + let redirect_uri = "https://example.com/rt-replay-callback"; 539 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 540 + let client_id = mock_client.uri(); 541 + 542 + let (code_verifier, code_challenge) = generate_pkce(); 543 + 544 + let par_body: Value = http_client 545 + .post(format!("{}/oauth/par", url)) 546 + .form(&[ 547 + ("response_type", "code"), 548 + ("client_id", &client_id), 549 + ("redirect_uri", redirect_uri), 550 + ("code_challenge", &code_challenge), 551 + ("code_challenge_method", "S256"), 552 + ]) 553 + .send() 554 + .await 555 + .unwrap() 556 + .json() 557 + .await 558 + .unwrap(); 559 + 560 + let request_uri = par_body["request_uri"].as_str().unwrap(); 561 + 562 + let auth_client = no_redirect_client(); 563 + let auth_res = auth_client 564 + .post(format!("{}/oauth/authorize", url)) 565 + .form(&[ 566 + ("request_uri", request_uri), 567 + ("username", &handle), 568 + ("password", password), 569 + ("remember_device", "false"), 570 + ]) 571 + .send() 572 + .await 573 + .unwrap(); 574 + 575 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 576 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 577 + 578 + let token_body: Value = http_client 579 + .post(format!("{}/oauth/token", url)) 580 + .form(&[ 581 + ("grant_type", "authorization_code"), 582 + ("code", code), 583 + ("redirect_uri", redirect_uri), 584 + ("code_verifier", &code_verifier), 585 + ("client_id", &client_id), 586 + ]) 587 + .send() 588 + .await 589 + .unwrap() 590 + .json() 591 + .await 592 + .unwrap(); 593 + 594 + let stolen_refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); 595 + 596 + let first_refresh: Value = http_client 597 + .post(format!("{}/oauth/token", url)) 598 + .form(&[ 599 + ("grant_type", "refresh_token"), 600 + ("refresh_token", &stolen_refresh_token), 601 + ("client_id", &client_id), 602 + ]) 603 + .send() 604 + .await 605 + .unwrap() 606 + .json() 607 + .await 608 + .unwrap(); 609 + 610 + assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); 611 + let new_refresh_token = first_refresh["refresh_token"].as_str().unwrap(); 612 + 613 + let replay_res = http_client 614 + .post(format!("{}/oauth/token", url)) 615 + .form(&[ 616 + ("grant_type", "refresh_token"), 617 + ("refresh_token", &stolen_refresh_token), 618 + ("client_id", &client_id), 619 + ]) 620 + .send() 621 + .await 622 + .unwrap(); 623 + 624 + assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail"); 625 + let body: Value = replay_res.json().await.unwrap(); 626 + assert_eq!(body["error"], "invalid_grant"); 627 + assert!( 628 + body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"), 629 + "Error should mention token reuse" 630 + ); 631 + 632 + let family_revoked_res = http_client 633 + .post(format!("{}/oauth/token", url)) 634 + .form(&[ 635 + ("grant_type", "refresh_token"), 636 + ("refresh_token", new_refresh_token), 637 + ("client_id", &client_id), 638 + ]) 639 + .send() 640 + .await 641 + .unwrap(); 642 + 643 + assert_eq!( 644 + family_revoked_res.status(), 645 + StatusCode::BAD_REQUEST, 646 + "Token family should be revoked after replay detection" 647 + ); 648 + } 649 + 650 + #[tokio::test] 651 + async fn test_security_redirect_uri_manipulation() { 652 + let url = base_url().await; 653 + let http_client = client(); 654 + 655 + let registered_redirect = "https://legitimate-app.com/callback"; 656 + let attacker_redirect = "https://attacker.com/steal"; 657 + let mock_client = setup_mock_client_metadata(registered_redirect).await; 658 + let client_id = mock_client.uri(); 659 + 660 + let (_, code_challenge) = generate_pkce(); 661 + 662 + let res = http_client 663 + .post(format!("{}/oauth/par", url)) 664 + .form(&[ 665 + ("response_type", "code"), 666 + ("client_id", &client_id), 667 + ("redirect_uri", attacker_redirect), 668 + ("code_challenge", &code_challenge), 669 + ("code_challenge_method", "S256"), 670 + ]) 671 + .send() 672 + .await 673 + .unwrap(); 674 + 675 + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected"); 676 + } 677 + 678 + #[tokio::test] 679 + async fn test_security_deactivated_account_blocked() { 680 + let url = base_url().await; 681 + let http_client = client(); 682 + 683 + let ts = Utc::now().timestamp_millis(); 684 + let handle = format!("deact-sec-{}", ts); 685 + let email = format!("deact-sec-{}@example.com", ts); 686 + let password = "deact-sec-password"; 687 + 688 + let create_res = http_client 689 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 690 + .json(&json!({ 691 + "handle": handle, 692 + "email": email, 693 + "password": password 694 + })) 695 + .send() 696 + .await 697 + .unwrap(); 698 + 699 + assert_eq!(create_res.status(), StatusCode::OK); 700 + let account: Value = create_res.json().await.unwrap(); 701 + let access_jwt = account["accessJwt"].as_str().unwrap(); 702 + 703 + let deact_res = http_client 704 + .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) 705 + .header("Authorization", format!("Bearer {}", access_jwt)) 706 + .json(&json!({})) 707 + .send() 708 + .await 709 + .unwrap(); 710 + assert_eq!(deact_res.status(), StatusCode::OK); 711 + 712 + let redirect_uri = "https://example.com/deact-sec-callback"; 713 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 714 + let client_id = mock_client.uri(); 715 + 716 + let (_, code_challenge) = generate_pkce(); 717 + 718 + let par_body: Value = http_client 719 + .post(format!("{}/oauth/par", url)) 720 + .form(&[ 721 + ("response_type", "code"), 722 + ("client_id", &client_id), 723 + ("redirect_uri", redirect_uri), 724 + ("code_challenge", &code_challenge), 725 + ("code_challenge_method", "S256"), 726 + ]) 727 + .send() 728 + .await 729 + .unwrap() 730 + .json() 731 + .await 732 + .unwrap(); 733 + 734 + let request_uri = par_body["request_uri"].as_str().unwrap(); 735 + 736 + let auth_res = http_client 737 + .post(format!("{}/oauth/authorize", url)) 738 + .form(&[ 739 + ("request_uri", request_uri), 740 + ("username", &handle), 741 + ("password", password), 742 + ("remember_device", "false"), 743 + ]) 744 + .send() 745 + .await 746 + .unwrap(); 747 + 748 + assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth"); 749 + let body: Value = auth_res.json().await.unwrap(); 750 + assert_eq!(body["error"], "access_denied"); 751 + } 752 + 753 + #[tokio::test] 754 + async fn test_security_url_injection_in_state_parameter() { 755 + let url = base_url().await; 756 + let http_client = client(); 757 + 758 + let ts = Utc::now().timestamp_millis(); 759 + let handle = format!("inject-state-{}", ts); 760 + let email = format!("inject-state-{}@example.com", ts); 761 + let password = "inject-state-password"; 762 + 763 + http_client 764 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 765 + .json(&json!({ 766 + "handle": handle, 767 + "email": email, 768 + "password": password 769 + })) 770 + .send() 771 + .await 772 + .unwrap(); 773 + 774 + let redirect_uri = "https://example.com/inject-callback"; 775 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 776 + let client_id = mock_client.uri(); 777 + 778 + let (code_verifier, code_challenge) = generate_pkce(); 779 + 780 + let malicious_state = "state&redirect_uri=https://attacker.com&extra="; 781 + 782 + let par_body: Value = http_client 783 + .post(format!("{}/oauth/par", url)) 784 + .form(&[ 785 + ("response_type", "code"), 786 + ("client_id", &client_id), 787 + ("redirect_uri", redirect_uri), 788 + ("code_challenge", &code_challenge), 789 + ("code_challenge_method", "S256"), 790 + ("state", malicious_state), 791 + ]) 792 + .send() 793 + .await 794 + .unwrap() 795 + .json() 796 + .await 797 + .unwrap(); 798 + 799 + let request_uri = par_body["request_uri"].as_str().unwrap(); 800 + 801 + let auth_client = no_redirect_client(); 802 + let auth_res = auth_client 803 + .post(format!("{}/oauth/authorize", url)) 804 + .form(&[ 805 + ("request_uri", request_uri), 806 + ("username", &handle), 807 + ("password", password), 808 + ("remember_device", "false"), 809 + ]) 810 + .send() 811 + .await 812 + .unwrap(); 813 + 814 + assert!(auth_res.status().is_redirection(), "Should redirect successfully"); 815 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 816 + 817 + assert!( 818 + location.starts_with(redirect_uri), 819 + "Redirect should go to registered URI, not attacker URI. Got: {}", 820 + location 821 + ); 822 + 823 + let redirect_uri_count = location.matches("redirect_uri=").count(); 824 + assert!( 825 + redirect_uri_count <= 1, 826 + "State injection should not add extra redirect_uri parameters" 827 + ); 828 + 829 + assert!( 830 + location.contains(&urlencoding::encode(malicious_state).to_string()) || 831 + location.contains("state=state%26redirect_uri"), 832 + "State parameter should be properly URL-encoded. Got: {}", 833 + location 834 + ); 835 + } 836 + 837 + #[tokio::test] 838 + async fn test_security_cross_client_token_theft() { 839 + let url = base_url().await; 840 + let http_client = client(); 841 + 842 + let ts = Utc::now().timestamp_millis(); 843 + let handle = format!("cross-client-{}", ts); 844 + let email = format!("cross-client-{}@example.com", ts); 845 + let password = "cross-client-password"; 846 + 847 + http_client 848 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 849 + .json(&json!({ 850 + "handle": handle, 851 + "email": email, 852 + "password": password 853 + })) 854 + .send() 855 + .await 856 + .unwrap(); 857 + 858 + let redirect_uri_a = "https://app-a.com/callback"; 859 + let mock_client_a = setup_mock_client_metadata(redirect_uri_a).await; 860 + let client_id_a = mock_client_a.uri(); 861 + 862 + let redirect_uri_b = "https://app-b.com/callback"; 863 + let mock_client_b = setup_mock_client_metadata(redirect_uri_b).await; 864 + let client_id_b = mock_client_b.uri(); 865 + 866 + let (code_verifier, code_challenge) = generate_pkce(); 867 + 868 + let par_body: Value = http_client 869 + .post(format!("{}/oauth/par", url)) 870 + .form(&[ 871 + ("response_type", "code"), 872 + ("client_id", &client_id_a), 873 + ("redirect_uri", redirect_uri_a), 874 + ("code_challenge", &code_challenge), 875 + ("code_challenge_method", "S256"), 876 + ]) 877 + .send() 878 + .await 879 + .unwrap() 880 + .json() 881 + .await 882 + .unwrap(); 883 + 884 + let request_uri = par_body["request_uri"].as_str().unwrap(); 885 + 886 + let auth_client = no_redirect_client(); 887 + let auth_res = auth_client 888 + .post(format!("{}/oauth/authorize", url)) 889 + .form(&[ 890 + ("request_uri", request_uri), 891 + ("username", &handle), 892 + ("password", password), 893 + ("remember_device", "false"), 894 + ]) 895 + .send() 896 + .await 897 + .unwrap(); 898 + 899 + let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 900 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 901 + 902 + let token_res = http_client 903 + .post(format!("{}/oauth/token", url)) 904 + .form(&[ 905 + ("grant_type", "authorization_code"), 906 + ("code", code), 907 + ("redirect_uri", redirect_uri_a), 908 + ("code_verifier", &code_verifier), 909 + ("client_id", &client_id_b), 910 + ]) 911 + .send() 912 + .await 913 + .unwrap(); 914 + 915 + assert_eq!( 916 + token_res.status(), 917 + StatusCode::BAD_REQUEST, 918 + "Cross-client code exchange must be explicitly rejected (defense-in-depth)" 919 + ); 920 + let body: Value = token_res.json().await.unwrap(); 921 + assert_eq!(body["error"], "invalid_grant"); 922 + assert!( 923 + body["error_description"].as_str().unwrap().contains("client_id"), 924 + "Error should mention client_id mismatch" 925 + ); 926 + } 927 + 928 + #[test] 929 + fn test_security_dpop_nonce_tamper_detection() { 930 + let secret = b"test-dpop-secret-32-bytes-long!!"; 931 + let verifier = DPoPVerifier::new(secret); 932 + 933 + let nonce = verifier.generate_nonce(); 934 + let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); 935 + 936 + let mut tampered = nonce_bytes.clone(); 937 + if !tampered.is_empty() { 938 + tampered[0] ^= 0xFF; 939 + } 940 + let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered); 941 + 942 + let result = verifier.validate_nonce(&tampered_nonce); 943 + assert!(result.is_err(), "Tampered nonce should be rejected"); 944 + } 945 + 946 + #[test] 947 + fn test_security_dpop_nonce_cross_server_rejected() { 948 + let secret1 = b"server-1-secret-32-bytes-long!!!"; 949 + let secret2 = b"server-2-secret-32-bytes-long!!!"; 950 + 951 + let verifier1 = DPoPVerifier::new(secret1); 952 + let verifier2 = DPoPVerifier::new(secret2); 953 + 954 + let nonce_from_server1 = verifier1.generate_nonce(); 955 + 956 + let result = verifier2.validate_nonce(&nonce_from_server1); 957 + assert!(result.is_err(), "Nonce from different server should be rejected"); 958 + } 959 + 960 + #[test] 961 + fn test_security_dpop_proof_signature_tampering() { 962 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 963 + use p256::elliptic_curve::sec1::ToEncodedPoint; 964 + 965 + let secret = b"test-dpop-secret-32-bytes-long!!"; 966 + let verifier = DPoPVerifier::new(secret); 967 + 968 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 969 + let verifying_key = signing_key.verifying_key(); 970 + let point = verifying_key.to_encoded_point(false); 971 + 972 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 973 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 974 + 975 + let header = json!({ 976 + "typ": "dpop+jwt", 977 + "alg": "ES256", 978 + "jwk": { 979 + "kty": "EC", 980 + "crv": "P-256", 981 + "x": x, 982 + "y": y 983 + } 984 + }); 985 + 986 + let payload = json!({ 987 + "jti": format!("tamper-test-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 988 + "htm": "POST", 989 + "htu": "https://example.com/token", 990 + "iat": Utc::now().timestamp() 991 + }); 992 + 993 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 994 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 995 + 996 + let signing_input = format!("{}.{}", header_b64, payload_b64); 997 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 998 + let mut sig_bytes = signature.to_bytes().to_vec(); 999 + 1000 + sig_bytes[0] ^= 0xFF; 1001 + let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes); 1002 + 1003 + let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig); 1004 + 1005 + let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None); 1006 + assert!(result.is_err(), "Tampered DPoP signature should be rejected"); 1007 + } 1008 + 1009 + #[test] 1010 + fn test_security_dpop_proof_key_substitution() { 1011 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 1012 + use p256::elliptic_curve::sec1::ToEncodedPoint; 1013 + 1014 + let secret = b"test-dpop-secret-32-bytes-long!!"; 1015 + let verifier = DPoPVerifier::new(secret); 1016 + 1017 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 1018 + 1019 + let attacker_key = SigningKey::random(&mut rand::thread_rng()); 1020 + let attacker_verifying = attacker_key.verifying_key(); 1021 + let attacker_point = attacker_verifying.to_encoded_point(false); 1022 + 1023 + let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap()); 1024 + let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap()); 1025 + 1026 + let header = json!({ 1027 + "typ": "dpop+jwt", 1028 + "alg": "ES256", 1029 + "jwk": { 1030 + "kty": "EC", 1031 + "crv": "P-256", 1032 + "x": x, 1033 + "y": y 1034 + } 1035 + }); 1036 + 1037 + let payload = json!({ 1038 + "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 1039 + "htm": "POST", 1040 + "htu": "https://example.com/token", 1041 + "iat": Utc::now().timestamp() 1042 + }); 1043 + 1044 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1045 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1046 + let signing_input = format!("{}.{}", header_b64, payload_b64); 1047 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 1048 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 1049 + 1050 + let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); 1051 + 1052 + let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None); 1053 + assert!(result.is_err(), "DPoP proof with mismatched key should be rejected"); 1054 + } 1055 + 1056 + #[test] 1057 + fn test_security_jwk_thumbprint_consistency() { 1058 + let jwk = DPoPJwk { 1059 + kty: "EC".to_string(), 1060 + crv: Some("P-256".to_string()), 1061 + x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), 1062 + y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()), 1063 + }; 1064 + 1065 + let mut results = Vec::new(); 1066 + for _ in 0..100 { 1067 + results.push(compute_jwk_thumbprint(&jwk).unwrap()); 1068 + } 1069 + 1070 + let first = &results[0]; 1071 + for (i, result) in results.iter().enumerate() { 1072 + assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i); 1073 + } 1074 + } 1075 + 1076 + #[test] 1077 + fn test_security_dpop_iat_clock_skew_limits() { 1078 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 1079 + use p256::elliptic_curve::sec1::ToEncodedPoint; 1080 + 1081 + let secret = b"test-dpop-secret-32-bytes-long!!"; 1082 + let verifier = DPoPVerifier::new(secret); 1083 + 1084 + let test_offsets = vec![ 1085 + (-600, true), 1086 + (-301, true), 1087 + (-299, false), 1088 + (0, false), 1089 + (299, false), 1090 + (301, true), 1091 + (600, true), 1092 + ]; 1093 + 1094 + for (offset_secs, should_fail) in test_offsets { 1095 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 1096 + let verifying_key = signing_key.verifying_key(); 1097 + let point = verifying_key.to_encoded_point(false); 1098 + 1099 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 1100 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 1101 + 1102 + let header = json!({ 1103 + "typ": "dpop+jwt", 1104 + "alg": "ES256", 1105 + "jwk": { 1106 + "kty": "EC", 1107 + "crv": "P-256", 1108 + "x": x, 1109 + "y": y 1110 + } 1111 + }); 1112 + 1113 + let payload = json!({ 1114 + "jti": format!("clock-{}-{}", offset_secs, Utc::now().timestamp_nanos_opt().unwrap_or(0)), 1115 + "htm": "POST", 1116 + "htu": "https://example.com/token", 1117 + "iat": Utc::now().timestamp() + offset_secs 1118 + }); 1119 + 1120 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1121 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1122 + let signing_input = format!("{}.{}", header_b64, payload_b64); 1123 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 1124 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 1125 + 1126 + let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); 1127 + 1128 + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 1129 + 1130 + if should_fail { 1131 + assert!(result.is_err(), "iat offset {} should be rejected", offset_secs); 1132 + } else { 1133 + assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs); 1134 + } 1135 + } 1136 + } 1137 + 1138 + #[test] 1139 + fn test_security_dpop_method_case_insensitivity() { 1140 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 1141 + use p256::elliptic_curve::sec1::ToEncodedPoint; 1142 + 1143 + let secret = b"test-dpop-secret-32-bytes-long!!"; 1144 + let verifier = DPoPVerifier::new(secret); 1145 + 1146 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 1147 + let verifying_key = signing_key.verifying_key(); 1148 + let point = verifying_key.to_encoded_point(false); 1149 + 1150 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 1151 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 1152 + 1153 + let header = json!({ 1154 + "typ": "dpop+jwt", 1155 + "alg": "ES256", 1156 + "jwk": { 1157 + "kty": "EC", 1158 + "crv": "P-256", 1159 + "x": x, 1160 + "y": y 1161 + } 1162 + }); 1163 + 1164 + let payload = json!({ 1165 + "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 1166 + "htm": "post", 1167 + "htu": "https://example.com/token", 1168 + "iat": Utc::now().timestamp() 1169 + }); 1170 + 1171 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1172 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1173 + let signing_input = format!("{}.{}", header_b64, payload_b64); 1174 + let signature: Signature = signing_key.sign(signing_input.as_bytes()); 1175 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 1176 + 1177 + let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64); 1178 + 1179 + let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 1180 + assert!(result.is_ok(), "HTTP method comparison should be case-insensitive"); 1181 + } 1182 + 1183 + #[tokio::test] 1184 + async fn test_security_invalid_grant_type_rejected() { 1185 + let url = base_url().await; 1186 + let http_client = client(); 1187 + 1188 + let grant_types = vec![ 1189 + "client_credentials", 1190 + "password", 1191 + "implicit", 1192 + "urn:ietf:params:oauth:grant-type:jwt-bearer", 1193 + "urn:ietf:params:oauth:grant-type:device_code", 1194 + "", 1195 + "AUTHORIZATION_CODE", 1196 + "Authorization_Code", 1197 + ]; 1198 + 1199 + for grant_type in grant_types { 1200 + let res = http_client 1201 + .post(format!("{}/oauth/token", url)) 1202 + .form(&[ 1203 + ("grant_type", grant_type), 1204 + ("client_id", "https://example.com"), 1205 + ]) 1206 + .send() 1207 + .await 1208 + .unwrap(); 1209 + 1210 + assert_eq!( 1211 + res.status(), 1212 + StatusCode::BAD_REQUEST, 1213 + "Grant type '{}' should be rejected", 1214 + grant_type 1215 + ); 1216 + } 1217 + } 1218 + 1219 + #[tokio::test] 1220 + async fn test_security_token_with_wrong_typ_rejected() { 1221 + let url = base_url().await; 1222 + let http_client = client(); 1223 + 1224 + let wrong_types = vec![ 1225 + "JWT", 1226 + "jwt", 1227 + "at+JWT", 1228 + "access_token", 1229 + "", 1230 + ]; 1231 + 1232 + for typ in wrong_types { 1233 + let header = json!({ 1234 + "alg": "HS256", 1235 + "typ": typ 1236 + }); 1237 + let payload = json!({ 1238 + "iss": "https://test.pds", 1239 + "sub": "did:plc:test", 1240 + "aud": "https://test.pds", 1241 + "iat": Utc::now().timestamp(), 1242 + "exp": Utc::now().timestamp() + 3600, 1243 + "jti": "wrong-typ-token" 1244 + }); 1245 + 1246 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1247 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1248 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); 1249 + let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); 1250 + 1251 + let res = http_client 1252 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1253 + .header("Authorization", format!("Bearer {}", token)) 1254 + .send() 1255 + .await 1256 + .unwrap(); 1257 + 1258 + assert_eq!( 1259 + res.status(), 1260 + StatusCode::UNAUTHORIZED, 1261 + "Token with typ='{}' should be rejected", 1262 + typ 1263 + ); 1264 + } 1265 + } 1266 + 1267 + #[tokio::test] 1268 + async fn test_security_missing_required_claims_rejected() { 1269 + let url = base_url().await; 1270 + let http_client = client(); 1271 + 1272 + let tokens_missing_claims = vec![ 1273 + (json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"), 1274 + (json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"), 1275 + (json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"), 1276 + ]; 1277 + 1278 + for (payload, missing_claim) in tokens_missing_claims { 1279 + let header = json!({ 1280 + "alg": "HS256", 1281 + "typ": "at+jwt" 1282 + }); 1283 + 1284 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1285 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1286 + let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]); 1287 + let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig); 1288 + 1289 + let res = http_client 1290 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1291 + .header("Authorization", format!("Bearer {}", token)) 1292 + .send() 1293 + .await 1294 + .unwrap(); 1295 + 1296 + assert_eq!( 1297 + res.status(), 1298 + StatusCode::UNAUTHORIZED, 1299 + "Token missing '{}' claim should be rejected", 1300 + missing_claim 1301 + ); 1302 + } 1303 + } 1304 + 1305 + #[tokio::test] 1306 + async fn test_security_malformed_tokens_rejected() { 1307 + let url = base_url().await; 1308 + let http_client = client(); 1309 + 1310 + let malformed_tokens = vec![ 1311 + "", 1312 + "not-a-token", 1313 + "one.two", 1314 + "one.two.three.four", 1315 + "....", 1316 + "eyJhbGciOiJIUzI1NiJ9", 1317 + "eyJhbGciOiJIUzI1NiJ9.", 1318 + "eyJhbGciOiJIUzI1NiJ9..", 1319 + ".eyJzdWIiOiJ0ZXN0In0.", 1320 + "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig", 1321 + "eyJhbGciOiJIUzI1NiJ9.!!invalid!!.sig", 1322 + ]; 1323 + 1324 + for token in malformed_tokens { 1325 + let res = http_client 1326 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1327 + .header("Authorization", format!("Bearer {}", token)) 1328 + .send() 1329 + .await 1330 + .unwrap(); 1331 + 1332 + assert_eq!( 1333 + res.status(), 1334 + StatusCode::UNAUTHORIZED, 1335 + "Malformed token '{}' should be rejected", 1336 + if token.len() > 50 { &token[..50] } else { token } 1337 + ); 1338 + } 1339 + } 1340 + 1341 + #[tokio::test] 1342 + async fn test_security_authorization_header_formats() { 1343 + let url = base_url().await; 1344 + let http_client = client(); 1345 + 1346 + let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 1347 + 1348 + let valid_case_variants = vec![ 1349 + format!("bearer {}", access_token), 1350 + format!("BEARER {}", access_token), 1351 + format!("Bearer {}", access_token), 1352 + ]; 1353 + 1354 + for auth_header in valid_case_variants { 1355 + let res = http_client 1356 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1357 + .header("Authorization", &auth_header) 1358 + .send() 1359 + .await 1360 + .unwrap(); 1361 + 1362 + assert_eq!( 1363 + res.status(), 1364 + StatusCode::OK, 1365 + "Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)", 1366 + if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } 1367 + ); 1368 + } 1369 + 1370 + let invalid_formats = vec![ 1371 + format!("Basic {}", access_token), 1372 + format!("Digest {}", access_token), 1373 + access_token.clone(), 1374 + format!("Bearer{}", access_token), 1375 + ]; 1376 + 1377 + for auth_header in invalid_formats { 1378 + let res = http_client 1379 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1380 + .header("Authorization", &auth_header) 1381 + .send() 1382 + .await 1383 + .unwrap(); 1384 + 1385 + assert_eq!( 1386 + res.status(), 1387 + StatusCode::UNAUTHORIZED, 1388 + "Auth header '{}...' should be rejected", 1389 + if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header } 1390 + ); 1391 + } 1392 + } 1393 + 1394 + #[tokio::test] 1395 + async fn test_security_no_authorization_header() { 1396 + let url = base_url().await; 1397 + let http_client = client(); 1398 + 1399 + let res = http_client 1400 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1401 + .send() 1402 + .await 1403 + .unwrap(); 1404 + 1405 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401"); 1406 + } 1407 + 1408 + #[tokio::test] 1409 + async fn test_security_empty_authorization_header() { 1410 + let url = base_url().await; 1411 + let http_client = client(); 1412 + 1413 + let res = http_client 1414 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1415 + .header("Authorization", "") 1416 + .send() 1417 + .await 1418 + .unwrap(); 1419 + 1420 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401"); 1421 + } 1422 + 1423 + #[tokio::test] 1424 + async fn test_security_revoked_token_rejected() { 1425 + let url = base_url().await; 1426 + let http_client = client(); 1427 + 1428 + let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await; 1429 + 1430 + let revoke_res = http_client 1431 + .post(format!("{}/oauth/revoke", url)) 1432 + .form(&[("token", &refresh_token)]) 1433 + .send() 1434 + .await 1435 + .unwrap(); 1436 + 1437 + assert_eq!(revoke_res.status(), StatusCode::OK); 1438 + 1439 + let introspect_res = http_client 1440 + .post(format!("{}/oauth/introspect", url)) 1441 + .form(&[("token", &access_token)]) 1442 + .send() 1443 + .await 1444 + .unwrap(); 1445 + 1446 + let introspect_body: Value = introspect_res.json().await.unwrap(); 1447 + assert_eq!(introspect_body["active"], false, "Revoked token should be inactive"); 1448 + }