+12
.config/nextest.toml
+12
.config/nextest.toml
+18
.sqlx/query-0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3.json
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
-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
-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
+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
-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
+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
+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
-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
-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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
-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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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
+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
+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
+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
+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
-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
+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
-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
+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
+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
+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
-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
+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
-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
+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
+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
+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
-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
+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
-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
+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
+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
+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
+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
+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
-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
-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
-2
migrations/202512211403_add_blocks_cids_to_repo_seq.sql
+29
scripts/run-tests.sh
+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
+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
+1
-1
src/api/admin/account.rs
+16
-33
src/api/feed/timeline.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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, ¶ms.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
+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
+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
+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
+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
+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
+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
+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
+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
+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(×tamp_bytes);
66
+
let hash = hasher.finalize();
67
+
68
+
let mut nonce_data = Vec::with_capacity(8 + 16);
69
+
nonce_data.extend_from_slice(×tamp_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(×tamp_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
+
}
+124
src/oauth/endpoints/metadata.rs
+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
+9
src/oauth/endpoints/mod.rs
+192
src/oauth/endpoints/par.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}