Initial oauth impl

This commit is contained in:
lewis
2025-12-11 20:05:59 +02:00
parent 0944ecf5bf
commit 17a7f1dc2b
115 changed files with 12007 additions and 1663 deletions
+12
View File
@@ -0,0 +1,12 @@
[store]
dir = "target/nextest"
[profile.default]
retries = 0
fail-fast = true
test-threads = "num-cpus"
[profile.ci]
retries = 2
fail-fast = false
test-threads = "num-cpus"
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "0198d73145b29c2b66c2bc437ff6578faa08d56a26b9aa98a311bd39547146b3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_account_device (did, device_id, created_at, updated_at)\n VALUES ($1, $2, NOW(), NOW())\n ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "03d4d87f64aa35c3e5d02ef6222dd35b56cb4e20ba631a66774968ed59418262"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "0afc7d45fdda0cb437988727a44c15d961ad6154cfb58a02ca05784a6c5b3e52"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_device\n SET last_seen_at = NOW()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0b413e61a11231b3d5ccb2ab4f0aa95a6701204873bc835f87d00f7cb5b87c78"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) as \"count!\" FROM oauth_token WHERE did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "0d4087a12feff131ddddb34eb3c702370555d99806455219e1d2ee59ced221eb"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT handle, email FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT data FROM oauth_authorized_client\n WHERE did = $1 AND client_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "1fca3948872f8abc5050865c18ab7b56d4ab98f0f1253afb57e2e4a9f5c04587"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_device WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "235620af9a007538bdbd6b7751a9ee287f06b7cd39b8e66f79bb4afe52bd0766"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, code = $4\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "26039af44364b143af3a9f09b50ab05fe4352811f9d74bb7dae72cc920162533"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_used_refresh_token (refresh_token, token_id)\n VALUES ($1, $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "2ea85a7507f974267cd300075ce6e60b3cfa5f705aed80879b30b5f3f120a8cc"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM sessions WHERE did = $1",
"query": "DELETE FROM session_tokens WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
@@ -10,5 +10,5 @@
},
"nullable": []
},
"hash": "9c42b607a971b3a102d247def6c6fd322013f3885e9d0232d6e846220f893c49"
"hash": "31fef6c193390b791edd988e40963706d4cc731cea6e19538794eb1588aa8b09"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 2,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "47c914ca6080c5cedf0c3f6ca7cd4cd49e8fb691b34d19511b7a1ab8b3606cdf"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "48ae289ec37b367a6ec3d74895acaf8c3dc93e65d243434b6947ead95ca8c416"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"Int4"
]
},
"nullable": []
},
"hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234"
}
@@ -0,0 +1,21 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_authorization_request\n (id, did, device_id, client_id, client_auth, parameters, expires_at, code)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Jsonb",
"Jsonb",
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "52b59474e567add52f112ccfaeb300ebf790cf4ecc1c243ad9563fa136c33550"
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "token_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "details",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "current_refresh_token",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "scope",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true
]
},
"hash": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765"
}
@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, u.id as user_id, u.email, u.handle, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "key_bytes",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "55c4e13e5ff23aaa71c3ab417891a5f56542571ba3f15c6d9dae153405bc4275"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
"query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
"describe": {
"columns": [
{
@@ -27,6 +27,11 @@
"ordinal": 4,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 5,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
@@ -39,8 +44,9 @@
false,
false,
false,
false
false,
true
]
},
"hash": "2305db96343fcb721adc4a6a608b64678f707928d3f9395070f5e21a5ca9b601"
"hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb"
}
@@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 2,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_token\n (did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"Timestamptz",
"Text",
"Jsonb",
"Text",
"Jsonb",
"Jsonb",
"Text",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "encryption_version",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "takedown_ref",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true
]
},
"hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Bytea",
"Int4"
]
},
"nullable": []
},
"hash": "73335e777fe754f55f384343f483747e84dc307b76738379ae018895b5182eb7"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_dpop_jti (jti)\n VALUES ($1)\n ON CONFLICT (jti) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "7b9fbadc505176c4afdb8a55ffeefa9f6a38924a3577f0b3ff77f7373aba4974"
}
@@ -1,17 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
"query": "SELECT k.key_bytes, k.encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
@@ -21,8 +21,8 @@
},
"nullable": [
false,
false
true
]
},
"hash": "f91a07e40484ade5b4c72addf62e4ad82feab312645c0b7a4ea69c0e55e17b14"
"hash": "7bb1388dec372fe749462cd9b604e5802b770aeb110462208988141d31c86c92"
}
@@ -1,58 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email,\n u.email_confirmation_code, u.email_confirmation_code_expires_at,\n u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 2,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "current_email",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "email_confirmation_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "email_confirmation_code_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "email_pending_verification",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "7d8993cdd6f859d38d1e017bbb2bd02278d75baec57b7d2c97ba590b52f8e2d9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM sessions WHERE access_jwt = $1",
"query": "DELETE FROM session_tokens WHERE access_jti = $1",
"describe": {
"columns": [],
"parameters": {
@@ -10,5 +10,5 @@
},
"nullable": []
},
"hash": "52437f0d7f91d29d7438263a1f658a838601038d911be8781b91ebeec8a54b89"
"hash": "847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6"
}
@@ -1,27 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT u.handle, u.did, u.email, k.key_bytes\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"query": "\n SELECT did, password_hash, deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email",
"ordinal": 1,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "key_bytes",
"type_info": "Bytea"
"name": "takedown_ref",
"type_info": "Text"
}
],
"parameters": {
@@ -32,9 +32,9 @@
"nullable": [
false,
false,
false,
false
true,
true
]
},
"hash": "09d75b756a6bd981cf2a9e922eccc38677bee474813c66465904aec3c0da1c3e"
"hash": "91ab872f41891370baf9d405e8812b8d4cfb0b7555430eb45f16fe550fac4b43"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT current_refresh_token FROM oauth_token WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "current_refresh_token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
true
]
},
"hash": "93eafc96f8007ae089dfb14b14601e9edb0d7341ebff2a99ccafcb9516fd2043"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a5b7ceaa177ef136a0e2421eaca3f3edf283e9305bd4675d72a1b7a02c3dfc83"
}
@@ -1,35 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 2,
"name": "user_id",
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email_confirmation_code",
"type_info": "Text"
},
{
"ordinal": 4,
"ordinal": 3,
"name": "email_confirmation_code_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "email_pending_verification",
"type_info": "Text"
}
@@ -40,7 +35,6 @@
]
},
"nullable": [
false,
false,
false,
true,
@@ -48,5 +42,5 @@
true
]
},
"hash": "3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d"
"hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "b51ed30a0421d19beba933234679b39dc7cc9b02d18bbce1958ac9b0ee6f6268"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email_confirmation_code",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "email_confirmation_code_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "email_pending_verification",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true
]
},
"hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5"
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE token_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "token_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "details",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "current_refresh_token",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "scope",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true
]
},
"hash": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_authorization_request\n WHERE expires_at < NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "b6a1284c921cdb40254965adbaf7c2c61c4dba6938287d85f247fec94fed5230"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token WHERE token_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "bab0b553f6ff88955ab84eac3fc958ab2f95944ab7f414d0b7256776c766c2a5"
}
@@ -0,0 +1,100 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE current_refresh_token = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "token_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "details",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "current_refresh_token",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "scope",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true
]
},
"hash": "bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "bcc1fb4f23f1486f0ff49c96ce2e6c5d24bd8963a82d52763d3b535d4af192f3"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Bytea"
]
},
"nullable": []
},
"hash": "c4c9842e69c5fd4f4a2ebc176078af2a5f98beb3ea4d3c6af5b1b8fed2ec50e3"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_token WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "c72a8fb702f63cd07e25cf3bd41c3f4673b08623fd9746ee960e59bae07681d5"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.refresh_jwt = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "c949b23cf6d795c58e4c35907628bbb85714e9c49a569653b17acab60e1674ac"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT session_id, user_agent, ip_address, last_seen_at\n FROM oauth_device\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "session_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "user_agent",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "ip_address",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "last_seen_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "c9dacba9ac1c6baec49e4b98117f803fff9b4cc722def305ba90218b0087798e"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "cb02d222787a1dea81f99ef25627c3439f7c754fce0c0460a293411e278ebd6b"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, handle FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "cd047d9291c29265659dfc4f94d254467ace166865ea60d27ee39737119872c1"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data)\n VALUES ($1, $2, NOW(), NOW(), $3)\n ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "cd88fece35ccc213ad5bdb7ad063c1e6e5b1e6d308c1f7800cdef9408c776789"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT key_bytes FROM user_keys WHERE user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "ce27e2da1f15cad97d2e31fda964e1d7017154fa559a8d9851728fb23af871cd"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 2,
"name": "user_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "d31423ddcb625250d7c15581e8c9242ec6290b41507eb710744ad900d482222d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "d402596403270a4cc6a2ce2050ba171155241a575bafacf859d65cd2c78f7367"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code\n FROM oauth_authorization_request\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "db9950690548510474a2bf755b4c4c103b284e82e3cf23d17fc99cd2fc728c64"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_authorization_request\n WHERE code = $1\n RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ef55a06bcea9b1a0d744df4fe353260ae4d6d93bbf5ea73133db65e38f6241ee"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n WHERE t.token_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "takedown_ref",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "efe82a97fd456c85dc7f51ece87f85950cca79fe0fac4ef6caa44fecf0911b07"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_dpop_jti\n WHERE created_at < NOW() - INTERVAL '1 second' * $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Float8"
]
},
"nullable": []
},
"hash": "f06350c8f7baa88205a6872c974286364170e74cd3a936b80f762ae6e83f1f8e"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_authorization_request WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f0faffe74f48c68bf98e6d3ec93ba3a410b41a7acc117f768033ca9a017f45ce"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "session_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535"
}
Generated
+91
View File
@@ -27,6 +27,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -865,6 +900,7 @@ dependencies = [
name = "bspds"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"async-trait",
"aws-config",
@@ -877,7 +913,10 @@ dependencies = [
"cid",
"ctor",
"dotenvy",
"ed25519-dalek",
"futures",
"hkdf",
"hmac",
"iroh-car",
"jacquard",
"jacquard-axum",
@@ -886,6 +925,8 @@ dependencies = [
"k256",
"multibase",
"multihash",
"p256 0.13.2",
"p384",
"rand 0.8.5",
"reqwest",
"serde",
@@ -894,6 +935,7 @@ dependencies = [
"serde_json",
"sha2",
"sqlx",
"subtle",
"testcontainers",
"testcontainers-modules",
"thiserror 2.0.17",
@@ -901,6 +943,7 @@ dependencies = [
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
"wiremock",
]
@@ -1303,6 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1322,6 +1366,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -2071,6 +2124,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -3610,6 +3673,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
@@ -3905,6 +3974,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
@@ -5855,6 +5936,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsigned-varint"
version = "0.7.2"
+10
View File
@@ -16,6 +16,9 @@ chrono = { version = "0.4.42", features = ["serde"] }
cid = "0.11.1"
dotenvy = "0.15.7"
futures = "0.3.30"
hkdf = "0.12"
hmac = "0.12"
aes-gcm = "0.10"
jacquard = { version = "0.9.3", default-features = false, features = ["api", "api_bluesky", "api_full", "derive", "dns"] }
jacquard-axum = "0.9.2"
jacquard-repo = "0.9.2"
@@ -30,12 +33,17 @@ serde_bytes = "0.11.14"
serde_ipld_dagcbor = "0.6.4"
serde_json = "1.0.145"
sha2 = "0.10.9"
subtle = "2.5"
p256 = { version = "0.13", features = ["ecdsa"] }
p384 = { version = "0.13", features = ["ecdsa"] }
ed25519-dalek = { version = "2.1", features = ["pkcs8"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] }
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "time", "signal", "process"] }
tracing = "0.1.43"
tracing-subscriber = "0.3.22"
tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
urlencoding = "2.1"
uuid = { version = "1.19.0", features = ["v4", "fast-rng"] }
[dev-dependencies]
@@ -44,3 +52,5 @@ iroh-car = "0.5.1"
testcontainers = "0.26.0"
testcontainers-modules = { version = "0.14.0", features = ["postgres"] }
wiremock = "0.6.5"
# urlencoding is also in dependencies, but tests use it directly
+47 -17
View File
@@ -110,23 +110,53 @@ Lewis' corrected big boy todofile
## Temp Namespace (`com.atproto.temp`)
- [ ] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups).
## OAuth 2.0 Support
The reference PDS implements full OAuth 2.0 provider functionality for native app authentication.
- [ ] OAuth Provider Core
- [ ] Implement `/.well-known/oauth-protected-resource` metadata endpoint.
- [ ] Implement `/.well-known/oauth-authorization-server` metadata endpoint.
- [ ] Implement `/oauth/authorize` authorization endpoint.
- [ ] Implement `/oauth/par` Pushed Authorization Request endpoint.
- [ ] Implement `/oauth/token` token endpoint.
- [ ] Implement `/oauth/jwks` JSON Web Key Set endpoint.
- [ ] OAuth Database Tables
- [ ] Device table for tracking authorized devices.
- [ ] Authorization request table.
- [ ] Authorized client table.
- [ ] Token table for OAuth tokens.
- [ ] Used refresh token table.
- [ ] DPoP (Demonstrating Proof-of-Possession) support.
- [ ] Client metadata fetching and validation.
## OAuth 2.1 Support
Full OAuth 2.1 provider for ATProto native app authentication.
- [x] OAuth Provider Core
- [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint.
- [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint.
- [x] Implement `/oauth/authorize` authorization endpoint (headless JSON mode).
- [x] Implement `/oauth/par` Pushed Authorization Request endpoint.
- [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants).
- [x] Implement `/oauth/jwks` JSON Web Key Set endpoint.
- [x] Implement `/oauth/revoke` token revocation endpoint.
- [x] Implement `/oauth/introspect` token introspection endpoint.
- [x] OAuth Database Tables
- [x] Device table for tracking authorized devices.
- [x] Authorization request table.
- [x] Authorized client table.
- [x] Token table for OAuth tokens.
- [x] Used refresh token table (replay protection).
- [x] DPoP JTI tracking table.
- [x] DPoP (Demonstrating Proof-of-Possession) support.
- [x] Client metadata fetching and validation.
- [x] PKCE (S256) enforcement.
- [x] OAuth token verification extractor for protected resources.
- [ ] Authorization UI templates (currently headless-only, returns JSON for programmatic flows).
- [ ] Implement `private_key_jwt` signature verification (currently rejects with clear error).
## OAuth Security Notes
I've tried to ensure that this codebase is not vulnerable to the following:
- Constant-time comparison for signature verification (prevents timing attacks)
- HMAC-SHA256 for access token signing with configurable secret
- Production secrets require 32+ character minimum
- DPoP JTI replay protection via database
- DPoP nonce validation with HMAC-based timestamps (5 min validity)
- Refresh token rotation with reuse detection (revokes token family on reuse)
- PKCE S256 enforced (plain not allowed)
- Authorization code single-use enforcement
- URL encoding for redirect parameters (prevents injection)
- All database queries use parameterized statements (no SQL injection)
- Deactivated/taken-down accounts blocked from OAuth authorization
- Client ID validation on token exchange (defense-in-depth against cross-client attacks)
### Auth Notes
- Algorithm choice: Using ES256K (secp256k1 ECDSA) with per-user keys. Ref PDS uses HS256 (HMAC) with single server key. Our approach provides better key isolation but differs from reference implementation.
- [ ] Support the ref PDS HS256 system too.
- Token storage: Now storing only token JTIs in session_tokens table (defense in depth against DB breaches). Refresh token family tracking enables detection of token reuse attacks.
- Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from MASTER_KEY environment variable. Migration-safe: supports both encrypted (version 1) and plaintext (version 0) keys.
## PDS-Level App Endpoints
These endpoints need to be implemented at the PDS level (not just proxied to appview).
+16 -21
View File
@@ -27,32 +27,27 @@ fmt-check:
lint: fmt-check clippy
test:
cargo test
# Run tests (auto-starts and auto-cleans containers)
test *args:
./scripts/run-tests.sh {{args}}
test-verbose:
cargo test -- --nocapture
# Run a specific test file
test-file file:
./scripts/run-tests.sh --test {{file}}
test-repo:
cargo test --test repo
# Run tests with testcontainers (slower, no shared infra)
test-standalone:
BSPDS_ALLOW_INSECURE_SECRETS=1 cargo test
test-lifecycle:
cargo test --test lifecycle
# Manually manage test infrastructure (for debugging)
test-infra-start:
./scripts/test-infra.sh start
test-proxy:
cargo test --test proxy
test-infra-stop:
./scripts/test-infra.sh stop
test-sync:
cargo test --test sync
test-server:
cargo test --test server
test-identity:
cargo test --test identity
test-auth:
cargo test --test auth
test-infra-status:
./scripts/test-infra.sh status
clean:
cargo clean
+125 -15
View File
@@ -18,15 +18,12 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- status & moderation
deactivated_at TIMESTAMPTZ,
invites_disabled BOOLEAN DEFAULT FALSE,
takedown_ref TEXT,
-- notifs
preferred_notification_channel notification_channel NOT NULL DEFAULT 'email',
-- auth & verification
password_reset_code TEXT,
password_reset_code_expires_at TIMESTAMPTZ,
@@ -54,11 +51,12 @@ CREATE TABLE IF NOT EXISTS invite_code_uses (
UNIQUE(code, used_by_user)
);
-- TODO: encrypt at rest!
CREATE TABLE IF NOT EXISTS user_keys (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
key_bytes BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
encrypted_at TIMESTAMPTZ,
encryption_version INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS repos (
@@ -68,14 +66,12 @@ CREATE TABLE IF NOT EXISTS repos (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- content addressable storage
CREATE TABLE IF NOT EXISTS blocks (
cid BYTEA PRIMARY KEY,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- denormalized index for fast queries
CREATE TABLE IF NOT EXISTS records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE,
@@ -97,13 +93,6 @@ CREATE TABLE IF NOT EXISTS blobs (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
access_jwt TEXT PRIMARY KEY,
refresh_jwt TEXT NOT NULL UNIQUE,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS app_passwords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -114,7 +103,6 @@ CREATE TABLE IF NOT EXISTS app_passwords (
UNIQUE(user_id, name)
);
-- naughty list
CREATE TABLE reports (
id BIGINT PRIMARY KEY,
reason_type TEXT NOT NULL,
@@ -155,3 +143,125 @@ CREATE INDEX idx_notification_queue_status_scheduled
WHERE status = 'pending';
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT,
public_key_did_key TEXT NOT NULL,
private_key_bytes BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL;
CREATE TABLE repo_seq (
seq BIGSERIAL PRIMARY KEY,
did TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type TEXT NOT NULL,
commit_cid TEXT,
prev_cid TEXT,
ops JSONB,
blobs TEXT[],
blocks_cids TEXT[]
);
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
CREATE TABLE IF NOT EXISTS session_tokens (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
access_jti TEXT NOT NULL UNIQUE,
refresh_jti TEXT NOT NULL UNIQUE,
access_expires_at TIMESTAMPTZ NOT NULL,
refresh_expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_session_tokens_did ON session_tokens(did);
CREATE INDEX idx_session_tokens_access_jti ON session_tokens(access_jti);
CREATE INDEX idx_session_tokens_refresh_jti ON session_tokens(refresh_jti);
CREATE TABLE IF NOT EXISTS used_refresh_tokens (
refresh_jti TEXT PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES session_tokens(id) ON DELETE CASCADE,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_used_refresh_tokens_session_id ON used_refresh_tokens(session_id);
CREATE TABLE IF NOT EXISTS oauth_device (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
user_agent TEXT,
ip_address TEXT NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oauth_authorization_request (
id TEXT PRIMARY KEY,
did TEXT REFERENCES users(did) ON DELETE CASCADE,
device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL,
client_id TEXT NOT NULL,
client_auth JSONB,
parameters JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
code TEXT UNIQUE
);
CREATE INDEX idx_oauth_auth_request_expires ON oauth_authorization_request(expires_at);
CREATE INDEX idx_oauth_auth_request_code ON oauth_authorization_request(code) WHERE code IS NOT NULL;
CREATE TABLE IF NOT EXISTS oauth_token (
id SERIAL PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
token_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
client_id TEXT NOT NULL,
client_auth JSONB NOT NULL,
device_id TEXT REFERENCES oauth_device(id) ON DELETE SET NULL,
parameters JSONB NOT NULL,
details JSONB,
code TEXT UNIQUE,
current_refresh_token TEXT UNIQUE,
scope TEXT
);
CREATE INDEX idx_oauth_token_did ON oauth_token(did);
CREATE INDEX idx_oauth_token_code ON oauth_token(code) WHERE code IS NOT NULL;
CREATE TABLE IF NOT EXISTS oauth_account_device (
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES oauth_device(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (did, device_id)
);
CREATE TABLE IF NOT EXISTS oauth_authorized_client (
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
client_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
data JSONB NOT NULL,
PRIMARY KEY (did, client_id)
);
CREATE TABLE IF NOT EXISTS oauth_used_refresh_token (
refresh_token TEXT PRIMARY KEY,
token_id INTEGER NOT NULL REFERENCES oauth_token(id) ON DELETE CASCADE
);
CREATE TABLE oauth_dpop_jti (
jti TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT,
public_key_did_key TEXT NOT NULL,
private_key_bytes BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL;
@@ -1,13 +0,0 @@
CREATE TABLE repo_seq (
seq BIGSERIAL PRIMARY KEY,
did TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type TEXT NOT NULL,
commit_cid TEXT,
prev_cid TEXT,
ops JSONB,
blobs TEXT[]
);
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
@@ -1,2 +0,0 @@
ALTER TABLE repo_seq ADD COLUMN blocks_cids TEXT[];
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INFRA_SCRIPT="$SCRIPT_DIR/test-infra.sh"
cleanup() {
echo ""
echo "Cleaning up test infrastructure..."
"$INFRA_SCRIPT" stop
}
trap cleanup EXIT
"$INFRA_SCRIPT" start
source "${TMPDIR:-/tmp}/bspds_test_infra.env"
echo ""
echo "Running database migrations..."
sqlx database create 2>/dev/null || true
sqlx migrate run --source "$PROJECT_DIR/migrations"
echo ""
echo "Running tests..."
echo ""
cargo nextest run "$@"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
INFRA_FILE="${TMPDIR:-/tmp}/bspds_test_infra.env"
CONTAINER_PREFIX="bspds-test"
command_exists() {
command -v "$1" >/dev/null 2>&1
}
if command_exists podman; then
CONTAINER_CMD="podman"
if [[ -z "${DOCKER_HOST:-}" ]]; then
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
PODMAN_SOCK="$RUNTIME_DIR/podman/podman.sock"
if [[ -S "$PODMAN_SOCK" ]]; then
export DOCKER_HOST="unix://$PODMAN_SOCK"
fi
fi
elif command_exists docker; then
CONTAINER_CMD="docker"
else
echo "Error: Neither podman nor docker found" >&2
exit 1
fi
start_infra() {
echo "Starting test infrastructure..."
if [[ -f "$INFRA_FILE" ]]; then
source "$INFRA_FILE"
if $CONTAINER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_PREFIX}-postgres$"; then
echo "Infrastructure already running (found $INFRA_FILE)"
cat "$INFRA_FILE"
return 0
fi
echo "Stale infra file found, cleaning up..."
rm -f "$INFRA_FILE"
fi
$CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true
echo "Starting PostgreSQL..."
$CONTAINER_CMD run -d \
--name "${CONTAINER_PREFIX}-postgres" \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_DB=postgres \
-P \
--label bspds_test=true \
postgres:18-alpine >/dev/null
echo "Starting MinIO..."
$CONTAINER_CMD run -d \
--name "${CONTAINER_PREFIX}-minio" \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
-P \
--label bspds_test=true \
minio/minio:latest server /data >/dev/null
echo "Waiting for services to be ready..."
sleep 2
PG_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-postgres" 5432 | head -1 | cut -d: -f2)
MINIO_PORT=$($CONTAINER_CMD port "${CONTAINER_PREFIX}-minio" 9000 | head -1 | cut -d: -f2)
for i in {1..30}; do
if $CONTAINER_CMD exec "${CONTAINER_PREFIX}-postgres" pg_isready -U postgres >/dev/null 2>&1; then
break
fi
echo "Waiting for PostgreSQL... ($i/30)"
sleep 1
done
for i in {1..30}; do
if curl -s "http://127.0.0.1:${MINIO_PORT}/minio/health/live" >/dev/null 2>&1; then
break
fi
echo "Waiting for MinIO... ($i/30)"
sleep 1
done
echo "Creating MinIO bucket..."
$CONTAINER_CMD run --rm --network host \
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
cat > "$INFRA_FILE" << EOF
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
export TEST_DB_PORT="${PG_PORT}"
export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}"
export S3_BUCKET="test-bucket"
export AWS_ACCESS_KEY_ID="minioadmin"
export AWS_SECRET_ACCESS_KEY="minioadmin"
export AWS_REGION="us-east-1"
export BSPDS_TEST_INFRA_READY="1"
export BSPDS_ALLOW_INSECURE_SECRETS="1"
EOF
echo ""
echo "Infrastructure ready!"
echo "Config written to: $INFRA_FILE"
echo ""
cat "$INFRA_FILE"
}
stop_infra() {
echo "Stopping test infrastructure..."
$CONTAINER_CMD rm -f "${CONTAINER_PREFIX}-postgres" "${CONTAINER_PREFIX}-minio" 2>/dev/null || true
rm -f "$INFRA_FILE"
echo "Infrastructure stopped."
}
status_infra() {
echo "Test Infrastructure Status:"
echo "============================"
if [[ -f "$INFRA_FILE" ]]; then
echo "Config file: $INFRA_FILE"
source "$INFRA_FILE"
echo "Database URL: $DATABASE_URL"
echo "S3 Endpoint: $S3_ENDPOINT"
else
echo "Config file: NOT FOUND"
fi
echo ""
echo "Containers:"
$CONTAINER_CMD ps -a --filter "label=bspds_test=true" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (none)"
}
case "${1:-}" in
start)
start_infra
;;
stop)
stop_infra
;;
restart)
stop_infra
start_infra
;;
status)
status_infra
;;
env)
if [[ -f "$INFRA_FILE" ]]; then
cat "$INFRA_FILE"
else
echo "Infrastructure not running. Run: $0 start" >&2
exit 1
fi
;;
*)
echo "Usage: $0 {start|stop|restart|status|env}"
echo ""
echo "Commands:"
echo " start - Start test infrastructure (Postgres, MinIO)"
echo " stop - Stop and remove test containers"
echo " restart - Stop then start infrastructure"
echo " status - Show infrastructure status"
echo " env - Output environment variables for sourcing"
exit 1
;;
esac
+1 -1
View File
@@ -214,7 +214,7 @@ pub async fn delete_account(
}
};
let _ = sqlx::query!("DELETE FROM sessions WHERE did = $1", did)
let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did)
.execute(&state.db)
.await;
+16 -33
View File
@@ -44,31 +44,22 @@ pub async fn get_timeline(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
token
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let (did, key_bytes) = match session {
Some(row) => (row.did, row.key_bytes),
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
@@ -77,15 +68,7 @@ pub async fn get_timeline(
}
};
if crate::auth::verify_token(&token, &key_bytes).is_err() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await;
+31 -11
View File
@@ -228,10 +228,23 @@ pub async fn create_account(
(secret_key.to_bytes().to_vec(), None)
};
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(enc) => enc,
Err(e) => {
error!("Error encrypting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let key_insert = sqlx::query!(
"INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)",
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
user_id,
&secret_key_bytes[..]
&encrypted_key_bytes[..],
crate::config::ENCRYPTION_VERSION
)
.execute(&mut *tx)
.await;
@@ -345,7 +358,7 @@ pub async fn create_account(
}
}
let access_jwt = crate::auth::create_access_token(&did, &secret_key_bytes[..]).map_err(|e| {
let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
error!("Error creating access token: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -353,12 +366,12 @@ pub async fn create_account(
)
.into_response()
});
let access_jwt = match access_jwt {
Ok(t) => t,
let access_meta = match access_meta {
Ok(m) => m,
Err(r) => return r,
};
let refresh_jwt = crate::auth::create_refresh_token(&did, &secret_key_bytes[..]).map_err(|e| {
let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
error!("Error creating refresh token: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -366,13 +379,20 @@ pub async fn create_account(
)
.into_response()
});
let refresh_jwt = match refresh_jwt {
Ok(t) => t,
let refresh_meta = match refresh_meta {
Ok(m) => m,
Err(r) => return r,
};
let session_insert =
sqlx::query!("INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)", access_jwt, refresh_jwt, did)
sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at
)
.execute(&mut *tx)
.await;
@@ -410,8 +430,8 @@ pub async fn create_account(
(
StatusCode::OK,
Json(CreateAccountOutput {
access_jwt,
refresh_jwt,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
handle: input.handle,
did,
}),
+74 -83
View File
@@ -121,12 +121,23 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
let key_row = sqlx::query!("SELECT key_bytes FROM user_keys WHERE user_id = $1", user_id)
let key_row = sqlx::query!("SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await;
let key_bytes: Vec<u8> = match key_row {
Ok(Some(row)) => row.key_bytes,
Ok(Some(row)) => {
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
}
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -270,45 +281,37 @@ pub async fn get_recommended_did_credentials(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.handle
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, handle) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.handle),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(ref user) => user.did.clone(),
Err(e) => {
error!("DB error in get_recommended_did_credentials: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -316,14 +319,18 @@ pub async fn get_recommended_did_credentials(
.into_response();
}
};
let handle = user.handle;
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let key_bytes = match auth_result.ok().and_then(|u| u.key_bytes) {
Some(kb) => kb,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot get DID credentials"})),
)
.into_response();
}
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_endpoint = format!("https://{}", hostname);
@@ -376,45 +383,37 @@ pub async fn update_handle(
headers: axum::http::HeaderMap,
Json(input): Json<UpdateHandleInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in update_handle: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -423,14 +422,6 @@ pub async fn update_handle(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let new_handle = input.handle.trim();
if new_handle.is_empty() {
return (
+17 -47
View File
@@ -33,60 +33,30 @@ pub async fn create_report(
headers: axum::http::HeaderMap,
Json(input): Json<CreateReportInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes) = match session {
Ok(Some(row)) => (row.did, row.key_bytes),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in create_report: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let valid_reason_types = [
"com.atproto.moderation.defs#reasonSpam",
+10 -9
View File
@@ -43,17 +43,18 @@ pub async fn proxy_handler(
let mut auth_header_val = headers.get("Authorization").map(|h| h.clone());
if let Some(aud) = &proxy_header {
if let Some(auth_val) = &auth_header_val {
if let Ok(token) = auth_val.to_str() {
let token = token.replace("Bearer ", "");
if let Ok(did) = crate::auth::get_did_from_token(&token) {
let key_row = sqlx::query!("SELECT k.key_bytes FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", did)
.fetch_optional(&state.db)
.await;
if let Some(token) = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
if let Ok(did) = crate::auth::get_did_from_token(&token) {
let key_row = sqlx::query!("SELECT k.key_bytes, k.encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1", did)
.fetch_optional(&state.db)
.await;
if let Ok(Some(row)) = key_row {
if let Ok(Some(row)) = key_row {
if let Ok(decrypted_key) = crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
if let Ok(new_token) =
crate::auth::create_service_token(&did, aud, &method, &row.key_bytes)
crate::auth::create_service_token(&did, aud, &method, &decrypted_key)
{
if let Ok(val) =
axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
+32 -64
View File
@@ -20,31 +20,22 @@ pub async fn upload_blob(
headers: axum::http::HeaderMap,
body: Bytes,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
token
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let (did, key_bytes) = match session {
Some(row) => (row.did, row.key_bytes),
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
@@ -52,14 +43,7 @@ pub async fn upload_blob(
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let did = auth_user.did;
let mime_type = headers
.get("content-type")
@@ -182,32 +166,22 @@ pub async fn list_missing_blobs(
headers: axum::http::HeaderMap,
Query(params): Query<ListMissingBlobsParams>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
token
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let (did, key_bytes) = match session {
Some(row) => (row.did, row.key_bytes),
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
@@ -216,13 +190,7 @@ pub async fn list_missing_blobs(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let did = auth_user.did;
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
+16 -31
View File
@@ -73,31 +73,22 @@ pub async fn apply_writes(
headers: axum::http::HeaderMap,
Json(input): Json<ApplyWritesInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
token
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let (did, key_bytes) = match session {
Some(row) => (row.did, row.key_bytes),
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
@@ -106,13 +97,7 @@ pub async fn apply_writes(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let did = auth_user.did;
if input.repo != did {
return (
+15 -33
View File
@@ -23,45 +23,27 @@ pub async fn prepare_repo_write(
headers: &HeaderMap,
repo_did: &str,
) -> Result<(String, Uuid, Cid), Response> {
let auth_header = headers.get("Authorization").ok_or_else(|| {
let token = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
).ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response()
})?;
let token = auth_header
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.access_jwt = $1",
token
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
error!("DB error fetching session: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()
})?
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response()
})?;
let auth_user = crate::auth::validate_bearer_token(&state.db, &token)
.await
.map_err(|_| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response()
})?;
crate::auth::verify_token(&token, &session.key_bytes).map_err(|_| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response()
})?;
if repo_did != session.did {
if repo_did != auth_user.did {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
@@ -69,7 +51,7 @@ pub async fn prepare_repo_write(
.into_response());
}
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", session.did)
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
.map_err(|e| {
@@ -108,7 +90,7 @@ pub async fn prepare_repo_write(
.into_response()
})?;
Ok((session.did, user_id, current_root_cid))
Ok((auth_user.did, user_id, current_root_cid))
}
#[derive(Deserialize)]
+88 -177
View File
@@ -30,45 +30,37 @@ pub async fn check_account_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in check_account_status: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -77,14 +69,6 @@ pub async fn check_account_status(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
@@ -139,60 +123,30 @@ pub async fn activate_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes) = match session {
Ok(Some(row)) => (row.did, row.key_bytes),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in activate_account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
.execute(&state.db)
@@ -222,60 +176,30 @@ pub async fn deactivate_account(
headers: axum::http::HeaderMap,
Json(_input): Json<DeactivateAccountInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes) = match session {
Ok(Some(row)) => (row.did, row.key_bytes),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in deactivate_account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did)
.execute(&state.db)
@@ -298,45 +222,37 @@ pub async fn request_account_delete(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, u.id as user_id, u.email, u.handle, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, user_id, email, handle, key_bytes) = match session {
Ok(Some(row)) => (row.did, row.user_id, row.email, row.handle, row.key_bytes),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in request_account_delete: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user = match sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -344,14 +260,9 @@ pub async fn request_account_delete(
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_id = user.id;
let email = user.email;
let handle = user.handle;
let confirmation_token = Uuid::new_v4().to_string();
let expires_at = Utc::now() + Duration::minutes(15);
@@ -541,7 +452,7 @@ pub async fn delete_account(
};
let deletion_result: Result<(), sqlx::Error> = async {
sqlx::query!("DELETE FROM sessions WHERE did = $1", did)
sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did)
.execute(&mut *tx)
.await?;
+75 -123
View File
@@ -26,45 +26,37 @@ pub async fn list_app_passwords(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in list_app_passwords: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -73,14 +65,6 @@ pub async fn list_app_passwords(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let result = sqlx::query!("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", user_id)
.fetch_all(&state.db)
.await;
@@ -131,45 +115,37 @@ pub async fn create_app_password(
headers: axum::http::HeaderMap,
Json(input): Json<CreateAppPasswordInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in create_app_password: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -178,14 +154,6 @@ pub async fn create_app_password(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let name = input.name.trim();
if name.is_empty() {
return (
@@ -275,45 +243,37 @@ pub async fn revoke_app_password(
headers: axum::http::HeaderMap,
Json(input): Json<RevokeAppPasswordInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in revoke_app_password: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -322,14 +282,6 @@ pub async fn revoke_app_password(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let name = input.name.trim();
if name.is_empty() {
return (
+92 -148
View File
@@ -30,45 +30,37 @@ pub async fn request_email_update(
headers: axum::http::HeaderMap,
Json(input): Json<RequestEmailUpdateInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id, u.handle
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id, handle) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in request_email_update: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -76,14 +68,8 @@ pub async fn request_email_update(
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_id = user.id;
let handle = user.handle;
let email = input.email.trim().to_lowercase();
if email.is_empty() {
@@ -159,52 +145,40 @@ pub async fn confirm_email(
headers: axum::http::HeaderMap,
Json(input): Json<ConfirmEmailInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session {
Ok(Some(row)) => (
row.did,
row.key_bytes,
row.user_id,
row.email_confirmation_code,
row.email_confirmation_code_expires_at,
row.email_pending_verification,
),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in confirm_email: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user = match sqlx::query!(
"SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -212,14 +186,10 @@ pub async fn confirm_email(
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_id = user.id;
let stored_code = user.email_confirmation_code;
let expires_at = user.email_confirmation_code_expires_at;
let email_pending_verification = user.email_pending_verification;
let email = input.email.trim().to_lowercase();
let confirmation_code = input.token.trim();
@@ -301,63 +271,40 @@ pub async fn update_email(
headers: axum::http::HeaderMap,
Json(input): Json<UpdateEmailInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email,
u.email_confirmation_code, u.email_confirmation_code_expires_at,
u.email_pending_verification
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (
_did,
key_bytes,
user_id,
current_email,
stored_code,
expires_at,
email_pending_verification,
) = match session {
Ok(Some(row)) => (
row.did,
row.key_bytes,
row.user_id,
row.current_email,
row.email_confirmation_code,
row.email_confirmation_code_expires_at,
row.email_pending_verification,
),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in update_email: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user = match sqlx::query!(
"SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -365,14 +312,11 @@ pub async fn update_email(
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let user_id = user.id;
let current_email = user.email;
let stored_code = user.email_confirmation_code;
let expires_at = user.email_confirmation_code_expires_at;
let email_pending_verification = user.email_pending_verification;
let new_email = input.email.trim().to_lowercase();
if new_email.is_empty() {
+75 -123
View File
@@ -27,14 +27,18 @@ pub async fn create_invite_code(
headers: axum::http::HeaderMap,
Json(input): Json<CreateInviteCodeInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if input.use_count < 1 {
return (
@@ -44,36 +48,24 @@ pub async fn create_invite_code(
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": e})),
)
.into_response();
}
Err(e) => {
error!("DB error in create_invite_code: {:?}", e);
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -82,14 +74,6 @@ pub async fn create_invite_code(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let creator_user_id = if let Some(for_account) = &input.for_account {
let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account)
.fetch_optional(&state.db)
@@ -184,14 +168,18 @@ pub async fn create_invite_codes(
headers: axum::http::HeaderMap,
Json(input): Json<CreateInviteCodesInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if input.use_count < 1 {
return (
@@ -201,36 +189,24 @@ pub async fn create_invite_codes(
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": e})),
)
.into_response();
}
Err(e) => {
error!("DB error in create_invite_codes: {:?}", e);
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -239,14 +215,6 @@ pub async fn create_invite_codes(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let code_count = input.code_count.unwrap_or(1).max(1);
let for_accounts = input.for_accounts.unwrap_or_default();
@@ -374,45 +342,37 @@ pub async fn get_account_invite_codes(
headers: axum::http::HeaderMap,
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes, user_id) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
error!("DB error in get_account_invite_codes: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -421,14 +381,6 @@ pub async fn get_account_invite_codes(
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let include_used = params.include_used.unwrap_or(true);
let codes_result = sqlx::query!(
+1 -1
View File
@@ -211,7 +211,7 @@ pub async fn reset_password(
.into_response();
}
let _ = sqlx::query!("DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", user_id)
let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id)
.execute(&state.db)
.await;
+318 -165
View File
@@ -27,60 +27,42 @@ pub async fn get_service_auth(
headers: axum::http::HeaderMap,
Query(params): Query<GetServiceAuthParams>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (did, key_bytes) = match session {
Ok(Some(row)) => (row.did, row.key_bytes),
Ok(None) => {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in get_service_auth: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let (did, key_bytes) = match auth_result {
Ok(user) => {
let kb = match user.key_bytes {
Some(kb) => kb,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot create service auth"})),
)
.into_response();
}
};
(user.did, kb)
}
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let lxm = params.lxm.as_deref().unwrap_or("*");
@@ -122,7 +104,7 @@ pub async fn create_session(
info!("create_session: identifier='{}'", input.identifier);
let user_row = sqlx::query!(
"SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
"SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
input.identifier
)
.fetch_optional(&state.db)
@@ -134,7 +116,17 @@ pub async fn create_session(
let stored_hash = &row.password_hash;
let did = &row.did;
let handle = &row.handle;
let key_bytes = &row.key_bytes;
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let password_valid = if verify(&input.password, stored_hash).unwrap_or(false) {
true
@@ -150,8 +142,8 @@ pub async fn create_session(
};
if password_valid {
let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) {
Ok(t) => t,
let access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return (
@@ -162,8 +154,8 @@ pub async fn create_session(
}
};
let refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) {
Ok(t) => t,
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create refresh token: {:?}", e);
return (
@@ -175,10 +167,12 @@ pub async fn create_session(
};
let session_insert = sqlx::query!(
"INSERT INTO sessions (access_jwt, refresh_jwt, did) VALUES ($1, $2, $3)",
access_jwt,
refresh_jwt,
did
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at
)
.execute(&state.db)
.await;
@@ -188,8 +182,8 @@ pub async fn create_session(
return (
StatusCode::OK,
Json(CreateSessionOutput {
access_jwt,
refresh_jwt,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
handle: handle.clone(),
did: did.clone(),
}),
@@ -236,45 +230,45 @@ pub async fn get_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Invalid Authorization header format"})),
)
.into_response();
}
};
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
Err(e) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e})),
)
.into_response();
}
};
let result = sqlx::query!(
r#"
SELECT u.handle, u.did, u.email, k.key_bytes
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
let user = sqlx::query!(
"SELECT handle, email FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await;
match result {
match user {
Ok(Some(row)) => {
if let Err(_) = crate::auth::verify_token(&token, &row.key_bytes) {
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"}))).into_response();
}
return (
StatusCode::OK,
Json(json!({
"handle": row.handle,
"did": row.did,
"did": did,
"email": row.email,
"didDoc": {}
})),
@@ -303,22 +297,71 @@ pub async fn delete_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let jti = match crate::auth::get_did_from_token(&token) {
Ok(_) => {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let claims_json = match URL_SAFE_NO_PAD.decode(parts[1]) {
Ok(bytes) => bytes,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let claims: serde_json::Value = match serde_json::from_slice(&claims_json) {
Ok(c) => c,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
match claims.get("jti").and_then(|j| j.as_str()) {
Some(jti) => jti.to_string(),
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
}
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let result = sqlx::query!("DELETE FROM sessions WHERE access_jwt = $1", token)
let result = sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti)
.execute(&state.db)
.await;
@@ -344,39 +387,114 @@ pub async fn refresh_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let refresh_token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let refresh_jti = {
let parts: Vec<&str> = refresh_token.split('.').collect();
if parts.len() != 3 {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token format"})),
)
.into_response();
}
let claims_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) {
Ok(b) => b,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let claims: serde_json::Value = match serde_json::from_slice(&claims_bytes) {
Ok(c) => c,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
match claims.get("jti").and_then(|j| j.as_str()) {
Some(jti) => jti.to_string(),
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
}
};
let reuse_check = sqlx::query_scalar!(
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
refresh_jti
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(session_id)) = reuse_check {
warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id);
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
.execute(&state.db)
.await;
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
Json(json!({"error": "ExpiredToken", "message": "Refresh token has been revoked due to suspected compromise"})),
)
.into_response();
}
let refresh_token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
"SELECT s.did, k.key_bytes FROM sessions s JOIN users u ON s.did = u.did JOIN user_keys k ON u.id = k.user_id WHERE s.refresh_jwt = $1",
refresh_token
)
.fetch_optional(&state.db)
.await;
r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
FROM session_tokens st
JOIN users u ON st.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()"#,
refresh_jti
)
.fetch_optional(&state.db)
.await;
match session {
Ok(Some(session_row)) => {
let session_id = session_row.id;
let did = &session_row.did;
let key_bytes = &session_row.key_bytes;
let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) {
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&refresh_token, &key_bytes) {
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token signature"}))).into_response();
if let Err(_) = crate::auth::verify_refresh_token(&refresh_token, &key_bytes) {
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"}))).into_response();
}
let new_access_jwt = match crate::auth::create_access_token(&did, &key_bytes) {
Ok(t) => t,
let new_access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return (
@@ -386,8 +504,8 @@ pub async fn refresh_session(
.into_response();
}
};
let new_refresh_jwt = match crate::auth::create_refresh_token(&did, &key_bytes) {
Ok(t) => t,
let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create refresh token: {:?}", e);
return (
@@ -398,54 +516,89 @@ pub async fn refresh_session(
}
};
let update = sqlx::query!(
"UPDATE sessions SET access_jwt = $1, refresh_jwt = $2 WHERE refresh_jwt = $3",
new_access_jwt,
new_refresh_jwt,
refresh_token
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)",
refresh_jti,
session_id
)
.execute(&state.db)
.await;
.execute(&mut *tx)
.await
{
error!("Failed to record used refresh token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
match update {
Ok(_) => {
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
if let Err(e) = sqlx::query!(
"UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
new_access_meta.jti,
new_refresh_meta.jti,
new_access_meta.expires_at,
new_refresh_meta.expires_at,
session_id
)
.execute(&mut *tx)
.await
{
error!("Database error updating session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
match user {
Ok(Some(u)) => {
return (
StatusCode::OK,
Json(json!({
"accessJwt": new_access_jwt,
"refreshJwt": new_refresh_jwt,
"handle": u.handle,
"did": did
})),
)
.into_response();
}
Ok(None) => {
error!("User not found for existing session: {}", did);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
Err(e) => {
error!("Database error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(u)) => {
return (
StatusCode::OK,
Json(json!({
"accessJwt": new_access_meta.token,
"refreshJwt": new_refresh_meta.token,
"handle": u.handle,
"did": did
})),
)
.into_response();
}
Ok(None) => {
error!("User not found for existing session: {}", did);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
Err(e) => {
error!("Database error updating session: {:?}", e);
error!("Database error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
+140
View File
@@ -0,0 +1,140 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts, header::AUTHORIZATION},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::state::AppState;
use super::{AuthenticatedUser, validate_bearer_token};
pub struct BearerAuth(pub AuthenticatedUser);
#[derive(Debug)]
pub enum AuthError {
MissingToken,
InvalidFormat,
AuthenticationFailed,
AccountDeactivated,
AccountTakedown,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error, message) = match self {
AuthError::MissingToken => (
StatusCode::UNAUTHORIZED,
"AuthenticationRequired",
"Authorization header is required",
),
AuthError::InvalidFormat => (
StatusCode::UNAUTHORIZED,
"InvalidToken",
"Invalid authorization header format",
),
AuthError::AuthenticationFailed => (
StatusCode::UNAUTHORIZED,
"AuthenticationFailed",
"Invalid or expired token",
),
AuthError::AccountDeactivated => (
StatusCode::UNAUTHORIZED,
"AccountDeactivated",
"Account is deactivated",
),
AuthError::AccountTakedown => (
StatusCode::UNAUTHORIZED,
"AccountTakedown",
"Account has been taken down",
),
};
(status, Json(json!({ "error": error, "message": message }))).into_response()
}
}
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
let auth_header = auth_header.trim();
if auth_header.len() < 8 {
return Err(AuthError::InvalidFormat);
}
let prefix = &auth_header[..7];
if !prefix.eq_ignore_ascii_case("bearer ") {
return Err(AuthError::InvalidFormat);
}
let token = auth_header[7..].trim();
if token.is_empty() {
return Err(AuthError::InvalidFormat);
}
Ok(token)
}
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
let header = auth_header?;
let header = header.trim();
if header.len() < 7 {
return None;
}
if !header[..7].eq_ignore_ascii_case("bearer ") {
return None;
}
let token = header[7..].trim();
if token.is_empty() {
return None;
}
Some(token.to_string())
}
impl FromRequestParts<AppState> for BearerAuth {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
match validate_bearer_token(&state.db, token).await {
Ok(user) => Ok(BearerAuth(user)),
Err("AccountDeactivated") => Err(AuthError::AccountDeactivated),
Err("AccountTakedown") => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bearer_token() {
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token("bearer abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token("BEARER abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
assert_eq!(extract_bearer_token(" Bearer abc123 ").unwrap(), "abc123");
assert!(extract_bearer_token("Basic abc123").is_err());
assert!(extract_bearer_token("Bearer").is_err());
assert!(extract_bearer_token("Bearer ").is_err());
assert!(extract_bearer_token("abc123").is_err());
assert!(extract_bearer_token("").is_err());
}
}
+119 -2
View File
@@ -1,10 +1,127 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
pub mod extractor;
pub mod token;
pub mod verify;
pub use token::{create_access_token, create_refresh_token, create_service_token};
pub use verify::{get_did_from_token, verify_token};
pub use extractor::{BearerAuth, AuthError, extract_bearer_token_from_header};
pub use token::{
create_access_token, create_refresh_token, create_service_token,
create_access_token_with_metadata, create_refresh_token_with_metadata,
TokenWithMetadata,
TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE,
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
};
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
pub struct AuthenticatedUser {
pub did: String,
pub key_bytes: Option<Vec<u8>>,
pub is_oauth: bool,
}
pub async fn validate_bearer_token(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, &'static str> {
validate_bearer_token_with_options(db, token, false).await
}
pub async fn validate_bearer_token_allow_deactivated(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, &'static str> {
validate_bearer_token_with_options(db, token, true).await
}
async fn validate_bearer_token_with_options(
db: &PgPool,
token: &str,
allow_deactivated: bool,
) -> Result<AuthenticatedUser, &'static str> {
let did_from_token = get_did_from_token(token).ok();
if let Some(ref did) = did_from_token {
if let Some(user) = sqlx::query!(
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
if !allow_deactivated && user.deactivated_at.is_some() {
return Err("AccountDeactivated");
}
if user.takedown_ref.is_some() {
return Err("AccountTakedown");
}
let decrypted_key = match crate::config::decrypt_key(&user.key_bytes, user.encryption_version) {
Ok(k) => k,
Err(_) => return Err("KeyDecryptionFailed"),
};
if let Ok(token_data) = verify_access_token(token, &decrypted_key) {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
did,
token_data.claims.jti
)
.fetch_optional(db)
.await
.ok()
.flatten();
if session_exists.is_some() {
return Ok(AuthenticatedUser {
did: did.clone(),
key_bytes: Some(decrypted_key),
is_oauth: false,
});
}
}
}
}
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
if let Some(oauth_token) = sqlx::query!(
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref
FROM oauth_token t
JOIN users u ON t.did = u.did
WHERE t.token_id = $1"#,
oauth_info.token_id
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
return Err("AccountDeactivated");
}
if oauth_token.takedown_ref.is_some() {
return Err("AccountTakedown");
}
let now = chrono::Utc::now();
if oauth_token.expires_at > now {
return Ok(AuthenticatedUser {
did: oauth_token.did,
key_bytes: None,
is_oauth: true,
});
}
}
}
Err("AuthenticationFailed")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
+45 -11
View File
@@ -2,16 +2,39 @@ use super::{Claims, Header};
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use uuid;
pub const TOKEN_TYPE_ACCESS: &str = "at+jwt";
pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt";
pub const TOKEN_TYPE_SERVICE: &str = "jwt";
pub const SCOPE_ACCESS: &str = "com.atproto.access";
pub const SCOPE_REFRESH: &str = "com.atproto.refresh";
pub const SCOPE_APP_PASS: &str = "com.atproto.appPass";
pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged";
pub struct TokenWithMetadata {
pub token: String,
pub jti: String,
pub expires_at: DateTime<Utc>,
}
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
create_signed_token(did, "access", key_bytes, Duration::minutes(15))
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
create_signed_token(did, "refresh", key_bytes, Duration::days(7))
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120))
}
pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90))
}
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
@@ -36,18 +59,20 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
sign_claims(claims, &signing_key)
}
fn create_signed_token(
fn create_signed_token_with_metadata(
did: &str,
scope: &str,
typ: &str,
key_bytes: &[u8],
duration: Duration,
) -> Result<String> {
) -> Result<TokenWithMetadata> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
let expires_at = Utc::now()
.checked_add_signed(duration)
.expect("valid timestamp")
.timestamp();
.expect("valid timestamp");
let expiration = expires_at.timestamp();
let jti = uuid::Uuid::new_v4().to_string();
let claims = Claims {
iss: did.to_owned(),
@@ -60,16 +85,25 @@ fn create_signed_token(
iat: Utc::now().timestamp() as usize,
scope: Some(scope.to_string()),
lxm: None,
jti: uuid::Uuid::new_v4().to_string(),
jti: jti.clone(),
};
sign_claims(claims, &signing_key)
let token = sign_claims_with_type(claims, &signing_key, typ)?;
Ok(TokenWithMetadata {
token,
jti,
expires_at,
})
}
fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> {
sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE)
}
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> {
let header = Header {
alg: "ES256K".to_string(),
typ: "JWT".to_string(),
typ: typ.to_string(),
};
let header_json = serde_json::to_string(&header)?;
+67 -1
View File
@@ -1,4 +1,5 @@
use super::{Claims, TokenData, UnsafeClaims};
use super::{Claims, Header, TokenData, UnsafeClaims};
use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED};
use anyhow::{Context, Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -21,7 +22,53 @@ pub fn get_did_from_token(token: &str) -> Result<String, String> {
Ok(claims.sub.unwrap_or(claims.iss))
}
pub fn get_jti_from_token(token: &str) -> Result<String, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
let claims: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
claims.get("jti")
.and_then(|j| j.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No jti claim in token".to_string())
}
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(token, key_bytes, None, None)
}
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(
token,
key_bytes,
Some(TOKEN_TYPE_ACCESS),
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
)
}
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_internal(
token,
key_bytes,
Some(TOKEN_TYPE_REFRESH),
Some(&[SCOPE_REFRESH]),
)
}
fn verify_token_internal(
token: &str,
key_bytes: &[u8],
expected_typ: Option<&str>,
allowed_scopes: Option<&[&str]>,
) -> Result<TokenData<Claims>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid token format"));
@@ -31,6 +78,18 @@ pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>>
let claims_b64 = parts[1];
let signature_b64 = parts[2];
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.context("Base64 decode of header failed")?;
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if let Some(expected) = expected_typ {
if header.typ != expected {
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.context("Base64 decode of signature failed")?;
@@ -56,5 +115,12 @@ pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>>
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
if !scopes.contains(&token_scope) {
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
}
Ok(TokenData { claims })
}
+170
View File
@@ -0,0 +1,170 @@
use aes_gcm::{
Aes256Gcm, KeyInit, Nonce,
aead::Aead,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hkdf::Hkdf;
use p256::ecdsa::SigningKey;
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
static CONFIG: OnceLock<AuthConfig> = OnceLock::new();
pub const ENCRYPTION_VERSION: i32 = 1;
pub struct AuthConfig {
jwt_secret: String,
dpop_secret: String,
#[allow(dead_code)]
signing_key: SigningKey,
pub signing_key_id: String,
pub signing_key_x: String,
pub signing_key_y: String,
key_encryption_key: [u8; 32],
}
impl AuthConfig {
pub fn init() -> &'static Self {
CONFIG.get_or_init(|| {
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-jwt-secret-not-for-production".to_string()
} else {
panic!(
"JWT_SECRET environment variable must be set in production. \
Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-dpop-secret-not-for-production".to_string()
} else {
panic!(
"DPOP_SECRET environment variable must be set in production. \
Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if jwt_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("JWT_SECRET must be at least 32 characters");
}
if dpop_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("DPOP_SECRET must be at least 32 characters");
}
let mut hasher = Sha256::new();
hasher.update(b"oauth-signing-key-derivation:");
hasher.update(jwt_secret.as_bytes());
let seed = hasher.finalize();
let signing_key = SigningKey::from_slice(&seed)
.expect("Failed to create signing key from seed");
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let signing_key_x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let signing_key_y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let mut kid_hasher = Sha256::new();
kid_hasher.update(signing_key_x.as_bytes());
kid_hasher.update(signing_key_y.as_bytes());
let kid_hash = kid_hasher.finalize();
let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]);
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
"test-master-key-not-for-production".to_string()
} else {
panic!(
"MASTER_KEY environment variable must be set in production. \
Set BSPDS_ALLOW_INSECURE_SECRETS=1 for development/testing."
);
}
});
if master_key.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
panic!("MASTER_KEY must be at least 32 characters");
}
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut key_encryption_key = [0u8; 32];
hk.expand(b"bspds-user-key-encryption", &mut key_encryption_key)
.expect("HKDF expansion failed");
AuthConfig {
jwt_secret,
dpop_secret,
signing_key,
signing_key_id,
signing_key_x,
signing_key_y,
key_encryption_key,
}
})
}
pub fn get() -> &'static Self {
CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first")
}
pub fn jwt_secret(&self) -> &str {
&self.jwt_secret
}
pub fn dpop_secret(&self) -> &str {
&self.dpop_secret
}
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
use rand::RngCore;
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&ciphertext);
Ok(result)
}
pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
if encrypted.len() < 12 {
return Err("Encrypted data too short".to_string());
}
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
let nonce = Nonce::from_slice(&encrypted[..12]);
let ciphertext = &encrypted[12..];
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))
}
}
pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, String> {
AuthConfig::get().encrypt_user_key(plaintext)
}
pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, String> {
match version.unwrap_or(0) {
0 => Ok(encrypted.to_vec()),
1 => AuthConfig::get().decrypt_user_key(encrypted),
v => Err(format!("Unknown encryption version: {}", v)),
}
}
+21
View File
@@ -1,6 +1,8 @@
pub mod api;
pub mod auth;
pub mod config;
pub mod notifications;
pub mod oauth;
pub mod repo;
pub mod state;
pub mod storage;
@@ -267,6 +269,25 @@ pub fn app(state: AppState) -> Router {
)
.route("/.well-known/did.json", get(api::identity::well_known_did))
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
// OAuth 2.1 endpoints
.route(
"/.well-known/oauth-protected-resource",
get(oauth::endpoints::oauth_protected_resource),
)
.route(
"/.well-known/oauth-authorization-server",
get(oauth::endpoints::oauth_authorization_server),
)
.route("/oauth/jwks", get(oauth::endpoints::oauth_jwks))
.route(
"/oauth/par",
post(oauth::endpoints::pushed_authorization_request),
)
.route("/oauth/authorize", get(oauth::endpoints::authorize_get))
.route("/oauth/authorize", post(oauth::endpoints::authorize_post))
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
.route("/oauth/introspect", post(oauth::endpoints::introspect_token))
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
.with_state(state)
}
+365
View File
@@ -0,0 +1,365 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::OAuthError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
pub client_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
pub redirect_uris: Vec<String>,
#[serde(default)]
pub grant_types: Vec<String>,
#[serde(default)]
pub response_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_bound_access_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
}
impl Default for ClientMetadata {
fn default() -> Self {
Self {
client_id: String::new(),
client_name: None,
client_uri: None,
logo_uri: None,
redirect_uris: Vec::new(),
grant_types: vec!["authorization_code".to_string()],
response_types: vec!["code".to_string()],
scope: None,
token_endpoint_auth_method: Some("none".to_string()),
dpop_bound_access_tokens: None,
jwks: None,
jwks_uri: None,
application_type: None,
}
}
}
#[derive(Clone)]
pub struct ClientMetadataCache {
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
http_client: Client,
cache_ttl_secs: u64,
}
struct CachedMetadata {
metadata: ClientMetadata,
cached_at: std::time::Instant,
}
impl ClientMetadataCache {
pub fn new(cache_ttl_secs: u64) -> Self {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::new(),
cache_ttl_secs,
}
}
pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(client_id) {
if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
return Ok(cached.metadata.clone());
}
}
}
let metadata = self.fetch_metadata(client_id).await?;
{
let mut cache = self.cache.write().await;
cache.insert(
client_id.to_string(),
CachedMetadata {
metadata: metadata.clone(),
cached_at: std::time::Instant::now(),
},
);
}
Ok(metadata)
}
async fn fetch_metadata(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
return Err(OAuthError::InvalidClient(
"client_id must be a URL".to_string(),
));
}
if client_id.starts_with("http://")
&& !client_id.contains("localhost")
&& !client_id.contains("127.0.0.1")
{
return Err(OAuthError::InvalidClient(
"Non-localhost client_id must use https".to_string(),
));
}
let response = self
.http_client
.get(client_id)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?;
if !response.status().is_success() {
return Err(OAuthError::InvalidClient(format!(
"Failed to fetch client metadata: HTTP {}",
response.status()
)));
}
let mut metadata: ClientMetadata = response
.json()
.await
.map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?;
if metadata.client_id.is_empty() {
metadata.client_id = client_id.to_string();
} else if metadata.client_id != client_id {
return Err(OAuthError::InvalidClient(
"client_id in metadata does not match request".to_string(),
));
}
self.validate_metadata(&metadata)?;
Ok(metadata)
}
fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> {
if metadata.redirect_uris.is_empty() {
return Err(OAuthError::InvalidClient(
"redirect_uris is required".to_string(),
));
}
for uri in &metadata.redirect_uris {
self.validate_redirect_uri_format(uri)?;
}
if !metadata.grant_types.is_empty()
&& !metadata.grant_types.contains(&"authorization_code".to_string())
{
return Err(OAuthError::InvalidClient(
"authorization_code grant type is required".to_string(),
));
}
if !metadata.response_types.is_empty()
&& !metadata.response_types.contains(&"code".to_string())
{
return Err(OAuthError::InvalidClient(
"code response type is required".to_string(),
));
}
Ok(())
}
pub fn validate_redirect_uri(
&self,
metadata: &ClientMetadata,
redirect_uri: &str,
) -> Result<(), OAuthError> {
if !metadata.redirect_uris.contains(&redirect_uri.to_string()) {
return Err(OAuthError::InvalidRequest(
"redirect_uri not registered for client".to_string(),
));
}
Ok(())
}
fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
if uri.contains('#') {
return Err(OAuthError::InvalidClient(
"redirect_uri must not contain a fragment".to_string(),
));
}
let parsed = reqwest::Url::parse(uri).map_err(|_| {
OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri))
})?;
let scheme = parsed.scheme();
if scheme == "http" {
let host = parsed.host_str().unwrap_or("");
if host != "localhost" && host != "127.0.0.1" && host != "[::1]" {
return Err(OAuthError::InvalidClient(
"http redirect_uri only allowed for localhost".to_string(),
));
}
} else if scheme == "https" {
} else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') {
if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) {
return Err(OAuthError::InvalidClient(format!(
"Invalid redirect_uri scheme: {}",
scheme
)));
}
} else {
return Err(OAuthError::InvalidClient(format!(
"Invalid redirect_uri scheme: {}",
scheme
)));
}
Ok(())
}
}
impl ClientMetadata {
pub fn requires_dpop(&self) -> bool {
self.dpop_bound_access_tokens.unwrap_or(false)
}
pub fn auth_method(&self) -> &str {
self.token_endpoint_auth_method
.as_deref()
.unwrap_or("none")
}
}
pub fn verify_client_auth(
metadata: &ClientMetadata,
client_auth: &super::ClientAuth,
) -> Result<(), OAuthError> {
let expected_method = metadata.auth_method();
match (expected_method, client_auth) {
("none", super::ClientAuth::None) => Ok(()),
("none", _) => Err(OAuthError::InvalidClient(
"Client is configured for no authentication, but credentials were provided".to_string(),
)),
("private_key_jwt", super::ClientAuth::PrivateKeyJwt { client_assertion }) => {
verify_private_key_jwt(metadata, client_assertion)
}
("private_key_jwt", _) => Err(OAuthError::InvalidClient(
"Client requires private_key_jwt authentication".to_string(),
)),
("client_secret_post", super::ClientAuth::SecretPost { .. }) => {
Err(OAuthError::InvalidClient(
"client_secret_post is not supported for ATProto OAuth".to_string(),
))
}
("client_secret_basic", super::ClientAuth::SecretBasic { .. }) => {
Err(OAuthError::InvalidClient(
"client_secret_basic is not supported for ATProto OAuth".to_string(),
))
}
(method, _) => Err(OAuthError::InvalidClient(format!(
"Unsupported or mismatched authentication method: {}",
method
))),
}
}
fn verify_private_key_jwt(
metadata: &ClientMetadata,
client_assertion: &str,
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let parts: Vec<&str> = client_assertion.split('.').collect();
if parts.len() != 3 {
return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string()));
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing alg in client_assertion".to_string())
})?;
if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") {
return Err(OAuthError::InvalidClient(format!(
"Unsupported client_assertion algorithm: {}",
alg
)));
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload encoding".to_string()))?;
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing iss in client_assertion".to_string())
})?;
if iss != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion iss does not match client_id".to_string(),
));
}
let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing sub in client_assertion".to_string())
})?;
if sub != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion sub does not match client_id".to_string(),
));
}
let exp = payload.get("exp").and_then(|e| e.as_i64()).ok_or_else(|| {
OAuthError::InvalidClient("Missing exp in client_assertion".to_string())
})?;
let now = chrono::Utc::now().timestamp();
if exp < now {
return Err(OAuthError::InvalidClient("client_assertion has expired".to_string()));
}
let iat = payload.get("iat").and_then(|i| i.as_i64());
if let Some(iat) = iat {
if iat > now + 60 {
return Err(OAuthError::InvalidClient(
"client_assertion iat is in the future".to_string(),
));
}
}
if metadata.jwks.is_none() && metadata.jwks_uri.is_none() {
return Err(OAuthError::InvalidClient(
"Client using private_key_jwt must have jwks or jwks_uri".to_string(),
));
}
Err(OAuthError::InvalidClient(
"private_key_jwt signature verification not yet implemented - use 'none' auth method".to_string(),
))
}
+641
View File
@@ -0,0 +1,641 @@
use chrono::{DateTime, Utc};
use serde::{de::DeserializeOwned, Serialize};
use sqlx::PgPool;
use super::{
AuthorizationRequestParameters, ClientAuth, DeviceData, OAuthError, RequestData, TokenData,
AuthorizedClientData,
};
fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> {
serde_json::to_value(value).map_err(|e| {
tracing::error!("JSON serialization error: {}", e);
OAuthError::ServerError("Internal serialization error".to_string())
})
}
fn from_json<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, OAuthError> {
serde_json::from_value(value).map_err(|e| {
tracing::error!("JSON deserialization error: {}", e);
OAuthError::ServerError("Internal data corruption".to_string())
})
}
pub async fn create_device(
pool: &PgPool,
device_id: &str,
data: &DeviceData,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at)
VALUES ($1, $2, $3, $4, $5)
"#,
device_id,
data.session_id,
data.user_agent,
data.ip_address,
data.last_seen_at,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceData>, OAuthError> {
let row = sqlx::query!(
r#"
SELECT session_id, user_agent, ip_address, last_seen_at
FROM oauth_device
WHERE id = $1
"#,
device_id
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| DeviceData {
session_id: r.session_id,
user_agent: r.user_agent,
ip_address: r.ip_address,
last_seen_at: r.last_seen_at,
}))
}
pub async fn update_device_last_seen(
pool: &PgPool,
device_id: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_device
SET last_seen_at = NOW()
WHERE id = $1
"#,
device_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
DELETE FROM oauth_device WHERE id = $1
"#,
device_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn create_authorization_request(
pool: &PgPool,
request_id: &str,
data: &RequestData,
) -> Result<(), OAuthError> {
let client_auth_json = match &data.client_auth {
Some(ca) => Some(to_json(ca)?),
None => None,
};
let parameters_json = to_json(&data.parameters)?;
sqlx::query!(
r#"
INSERT INTO oauth_authorization_request
(id, did, device_id, client_id, client_auth, parameters, expires_at, code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
request_id,
data.did,
data.device_id,
data.client_id,
client_auth_json,
parameters_json,
data.expires_at,
data.code,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_authorization_request(
pool: &PgPool,
request_id: &str,
) -> Result<Option<RequestData>, OAuthError> {
let row = sqlx::query!(
r#"
SELECT did, device_id, client_id, client_auth, parameters, expires_at, code
FROM oauth_authorization_request
WHERE id = $1
"#,
request_id
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => {
let client_auth: Option<ClientAuth> = match r.client_auth {
Some(v) => Some(from_json(v)?),
None => None,
};
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
Ok(Some(RequestData {
client_id: r.client_id,
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did,
device_id: r.device_id,
code: r.code,
}))
}
None => Ok(None),
}
}
pub async fn update_authorization_request(
pool: &PgPool,
request_id: &str,
did: &str,
device_id: Option<&str>,
code: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET did = $2, device_id = $3, code = $4
WHERE id = $1
"#,
request_id,
did,
device_id,
code
)
.execute(pool)
.await?;
Ok(())
}
pub async fn consume_authorization_request_by_code(
pool: &PgPool,
code: &str,
) -> Result<Option<RequestData>, OAuthError> {
let row = sqlx::query!(
r#"
DELETE FROM oauth_authorization_request
WHERE code = $1
RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code
"#,
code
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => {
let client_auth: Option<ClientAuth> = match r.client_auth {
Some(v) => Some(from_json(v)?),
None => None,
};
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
Ok(Some(RequestData {
client_id: r.client_id,
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did,
device_id: r.device_id,
code: r.code,
}))
}
None => Ok(None),
}
}
pub async fn delete_authorization_request(
pool: &PgPool,
request_id: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
DELETE FROM oauth_authorization_request WHERE id = $1
"#,
request_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result<u64, OAuthError> {
let result = sqlx::query!(
r#"
DELETE FROM oauth_authorization_request
WHERE expires_at < NOW()
"#
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn create_token(
pool: &PgPool,
data: &TokenData,
) -> Result<i32, OAuthError> {
let client_auth_json = to_json(&data.client_auth)?;
let parameters_json = to_json(&data.parameters)?;
let row = sqlx::query!(
r#"
INSERT INTO oauth_token
(did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
"#,
data.did,
data.token_id,
data.created_at,
data.updated_at,
data.expires_at,
data.client_id,
client_auth_json,
data.device_id,
parameters_json,
data.details,
data.code,
data.current_refresh_token,
data.scope,
)
.fetch_one(pool)
.await?;
Ok(row.id)
}
pub async fn get_token_by_id(
pool: &PgPool,
token_id: &str,
) -> Result<Option<TokenData>, OAuthError> {
let row = sqlx::query!(
r#"
SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
FROM oauth_token
WHERE token_id = $1
"#,
token_id
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some(TokenData {
did: r.did,
token_id: r.token_id,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
})),
None => Ok(None),
}
}
pub async fn get_token_by_refresh_token(
pool: &PgPool,
refresh_token: &str,
) -> Result<Option<(i32, TokenData)>, OAuthError> {
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
FROM oauth_token
WHERE current_refresh_token = $1
"#,
refresh_token
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some((
r.id,
TokenData {
did: r.did,
token_id: r.token_id,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
},
))),
None => Ok(None),
}
}
pub async fn rotate_token(
pool: &PgPool,
old_db_id: i32,
new_token_id: &str,
new_refresh_token: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), OAuthError> {
let mut tx = pool.begin().await?;
let old_refresh = sqlx::query_scalar!(
r#"
SELECT current_refresh_token FROM oauth_token WHERE id = $1
"#,
old_db_id
)
.fetch_one(&mut *tx)
.await?;
if let Some(old_rt) = old_refresh {
sqlx::query!(
r#"
INSERT INTO oauth_used_refresh_token (refresh_token, token_id)
VALUES ($1, $2)
"#,
old_rt,
old_db_id
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
r#"
UPDATE oauth_token
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()
WHERE id = $1
"#,
old_db_id,
new_token_id,
new_refresh_token,
new_expires_at
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn check_refresh_token_used(
pool: &PgPool,
refresh_token: &str,
) -> Result<Option<i32>, OAuthError> {
let row = sqlx::query_scalar!(
r#"
SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1
"#,
refresh_token
)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
DELETE FROM oauth_token WHERE token_id = $1
"#,
token_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> {
sqlx::query!(
r#"
DELETE FROM oauth_token WHERE id = $1
"#,
db_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn upsert_account_device(
pool: &PgPool,
did: &str,
device_id: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
INSERT INTO oauth_account_device (did, device_id, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW()
"#,
did,
device_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn upsert_authorized_client(
pool: &PgPool,
did: &str,
client_id: &str,
data: &AuthorizedClientData,
) -> Result<(), OAuthError> {
let data_json = to_json(data)?;
sqlx::query!(
r#"
INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data)
VALUES ($1, $2, NOW(), NOW(), $3)
ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3
"#,
did,
client_id,
data_json
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_authorized_client(
pool: &PgPool,
did: &str,
client_id: &str,
) -> Result<Option<AuthorizedClientData>, OAuthError> {
let row = sqlx::query_scalar!(
r#"
SELECT data FROM oauth_authorized_client
WHERE did = $1 AND client_id = $2
"#,
did,
client_id
)
.fetch_optional(pool)
.await?;
match row {
Some(v) => Ok(Some(from_json(v)?)),
None => Ok(None),
}
}
pub async fn list_tokens_for_user(
pool: &PgPool,
did: &str,
) -> Result<Vec<TokenData>, OAuthError> {
let rows = sqlx::query!(
r#"
SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
FROM oauth_token
WHERE did = $1
"#,
did
)
.fetch_all(pool)
.await?;
let mut tokens = Vec::with_capacity(rows.len());
for r in rows {
tokens.push(TokenData {
did: r.did,
token_id: r.token_id,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
});
}
Ok(tokens)
}
pub async fn check_and_record_dpop_jti(
pool: &PgPool,
jti: &str,
) -> Result<bool, OAuthError> {
let result = sqlx::query!(
r#"
INSERT INTO oauth_dpop_jti (jti)
VALUES ($1)
ON CONFLICT (jti) DO NOTHING
"#,
jti
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn cleanup_expired_dpop_jtis(
pool: &PgPool,
max_age_secs: i64,
) -> Result<u64, OAuthError> {
let result = sqlx::query!(
r#"
DELETE FROM oauth_dpop_jti
WHERE created_at < NOW() - INTERVAL '1 second' * $1
"#,
max_age_secs as f64
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> {
let count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1
"#,
did
)
.fetch_one(pool)
.await?;
Ok(count)
}
pub async fn delete_oldest_tokens_for_user(
pool: &PgPool,
did: &str,
keep_count: i64,
) -> Result<u64, OAuthError> {
let result = sqlx::query!(
r#"
DELETE FROM oauth_token
WHERE id IN (
SELECT id FROM oauth_token
WHERE did = $1
ORDER BY updated_at ASC
OFFSET $2
)
"#,
did,
keep_count
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
const MAX_TOKENS_PER_USER: i64 = 100;
pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> {
let count = count_tokens_for_user(pool, did).await?;
if count > MAX_TOKENS_PER_USER {
let to_keep = MAX_TOKENS_PER_USER - 1;
delete_oldest_tokens_for_user(pool, did, to_keep).await?;
}
Ok(())
}
+421
View File
@@ -0,0 +1,421 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::OAuthError;
const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
const DPOP_MAX_AGE_SECS: i64 = 300;
#[derive(Debug, Clone)]
pub struct DPoPVerifyResult {
pub jkt: String,
pub jti: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPProofHeader {
pub typ: String,
pub alg: String,
pub jwk: DPoPJwk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPJwk {
pub kty: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub crv: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DPoPProofPayload {
pub jti: String,
pub htm: String,
pub htu: String,
pub iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ath: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
pub struct DPoPVerifier {
secret: Vec<u8>,
}
impl DPoPVerifier {
pub fn new(secret: &[u8]) -> Self {
Self {
secret: secret.to_vec(),
}
}
pub fn generate_nonce(&self) -> String {
let timestamp = Utc::now().timestamp();
let timestamp_bytes = timestamp.to_be_bytes();
let mut hasher = Sha256::new();
hasher.update(&self.secret);
hasher.update(&timestamp_bytes);
let hash = hasher.finalize();
let mut nonce_data = Vec::with_capacity(8 + 16);
nonce_data.extend_from_slice(&timestamp_bytes);
nonce_data.extend_from_slice(&hash[..16]);
URL_SAFE_NO_PAD.encode(&nonce_data)
}
pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> {
let nonce_bytes = URL_SAFE_NO_PAD
.decode(nonce)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?;
if nonce_bytes.len() < 24 {
return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string()));
}
let timestamp_bytes: [u8; 8] = nonce_bytes[..8]
.try_into()
.map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce".to_string()))?;
let timestamp = i64::from_be_bytes(timestamp_bytes);
let now = Utc::now().timestamp();
if now - timestamp > DPOP_NONCE_VALIDITY_SECS {
return Err(OAuthError::UseDpopNonce(self.generate_nonce()));
}
let mut hasher = Sha256::new();
hasher.update(&self.secret);
hasher.update(&timestamp_bytes);
let expected_hash = hasher.finalize();
if nonce_bytes[8..24] != expected_hash[..16] {
return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string()));
}
Ok(())
}
pub fn verify_proof(
&self,
dpop_header: &str,
http_method: &str,
http_uri: &str,
access_token_hash: Option<&str>,
) -> Result<DPoPVerifyResult, OAuthError> {
let parts: Vec<&str> = dpop_header.split('.').collect();
if parts.len() != 3 {
return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string()));
}
let header_json = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|_| OAuthError::InvalidDpopProof("Invalid header encoding".to_string()))?;
let payload_json = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| OAuthError::InvalidDpopProof("Invalid payload encoding".to_string()))?;
let header: DPoPProofHeader = serde_json::from_slice(&header_json)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid header JSON".to_string()))?;
let payload: DPoPProofPayload = serde_json::from_slice(&payload_json)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?;
if header.typ != "dpop+jwt" {
return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string()));
}
if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") {
return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string()));
}
if payload.htm.to_uppercase() != http_method.to_uppercase() {
return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string()));
}
let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu);
let request_uri = http_uri.split('?').next().unwrap_or(http_uri);
if proof_uri != request_uri {
return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string()));
}
let now = Utc::now().timestamp();
if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS {
return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string()));
}
if let Some(nonce) = &payload.nonce {
self.validate_nonce(nonce)?;
}
if let Some(expected_ath) = access_token_hash {
match &payload.ath {
Some(ath) if ath == expected_ath => {}
Some(_) => {
return Err(OAuthError::InvalidDpopProof(
"Access token hash mismatch".to_string(),
));
}
None => {
return Err(OAuthError::InvalidDpopProof(
"Missing access token hash".to_string(),
));
}
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?;
let signing_input = format!("{}.{}", parts[0], parts[1]);
verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?;
let jkt = compute_jwk_thumbprint(&header.jwk)?;
Ok(DPoPVerifyResult {
jkt,
jti: payload.jti.clone(),
})
}
}
fn verify_dpop_signature(
alg: &str,
jwk: &DPoPJwk,
message: &[u8],
signature: &[u8],
) -> Result<(), OAuthError> {
match alg {
"ES256" => verify_es256(jwk, message, signature),
"ES384" => verify_es384(jwk, message, signature),
"EdDSA" => verify_eddsa(jwk, message, signature),
_ => Err(OAuthError::InvalidDpopProof(format!(
"Unsupported algorithm: {}",
alg
))),
}
}
fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
use p256::elliptic_curve::sec1::FromEncodedPoint;
use p256::{AffinePoint, EncodedPoint};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for ES256".to_string())
})?;
if crv != "P-256" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for ES256: {}",
crv
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD
.decode(jwk.y.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
})?)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
let point = EncodedPoint::from_affine_coordinates(
x_bytes.as_slice().into(),
y_bytes.as_slice().into(),
false,
);
let affine = AffinePoint::from_encoded_point(&point);
if affine.is_none().into() {
return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string()));
}
let verifying_key = VerifyingKey::from_affine(affine.unwrap())
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
let sig = Signature::from_slice(signature)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?;
verifying_key
.verify(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use p384::ecdsa::signature::Verifier;
use p384::ecdsa::{Signature, VerifyingKey};
use p384::elliptic_curve::sec1::FromEncodedPoint;
use p384::{AffinePoint, EncodedPoint};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for ES384".to_string())
})?;
if crv != "P-384" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for ES384: {}",
crv
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD
.decode(jwk.y.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
})?)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
let point = EncodedPoint::from_affine_coordinates(
x_bytes.as_slice().into(),
y_bytes.as_slice().into(),
false,
);
let affine = AffinePoint::from_encoded_point(&point);
if affine.is_none().into() {
return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string()));
}
let verifying_key = VerifyingKey::from_affine(affine.unwrap())
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
let sig = Signature::from_slice(signature)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?;
verifying_key
.verify(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use ed25519_dalek::{Signature, VerifyingKey};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string())
})?;
if crv != "Ed25519" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for EdDSA: {}",
crv
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| {
OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string())
})?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?;
let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| {
OAuthError::InvalidDpopProof("Invalid Ed25519 signature length".to_string())
})?;
let sig = Signature::from_bytes(&sig_bytes);
verifying_key
.verify_strict(message, &sig)
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
}
pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
let canonical = match jwk.kty.as_str() {
"EC" => {
let crv = jwk
.crv
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?;
let x = jwk
.x
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?;
let y = jwk
.y
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?;
format!(
r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
crv, x, y
)
}
"OKP" => {
let crv = jwk
.crv
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?;
let x = jwk
.x
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?;
format!(r#"{{"crv":"{}","kty":"OKP","x":"{}"}}"#, crv, x)
}
_ => {
return Err(OAuthError::InvalidDpopProof(
"Unsupported key type".to_string(),
));
}
};
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let hash = hasher.finalize();
Ok(URL_SAFE_NO_PAD.encode(&hash))
}
pub fn compute_access_token_hash(access_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(access_token.as_bytes());
let hash = hasher.finalize();
URL_SAFE_NO_PAD.encode(&hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_generation_and_validation() {
let secret = b"test-secret-key-32-bytes-long!!!";
let verifier = DPoPVerifier::new(secret);
let nonce = verifier.generate_nonce();
assert!(verifier.validate_nonce(&nonce).is_ok());
}
#[test]
fn test_jwk_thumbprint_ec() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("test_x".to_string()),
y: Some("test_y".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
assert!(!thumbprint.is_empty());
}
}
+210
View File
@@ -0,0 +1,210 @@
use axum::{
Form, Json,
extract::{Query, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use urlencoding::encode as url_encode;
use crate::state::AppState;
use crate::oauth::{Code, DeviceData, DeviceId, OAuthError, SessionId, db};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"0.0.0.0".to_string()
}
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeQuery {
pub request_uri: Option<String>,
pub client_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AuthorizeResponse {
pub client_id: String,
pub client_name: Option<String>,
pub scope: Option<String>,
pub redirect_uri: String,
pub state: Option<String>,
pub login_hint: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeSubmit {
pub request_uri: String,
pub username: String,
pub password: String,
#[serde(default)]
pub remember_device: bool,
}
pub async fn authorize_get(
State(state): State<AppState>,
Query(query): Query<AuthorizeQuery>,
) -> Result<Json<AuthorizeResponse>, OAuthError> {
let request_uri = query.request_uri.ok_or_else(|| {
OAuthError::InvalidRequest("request_uri is required".to_string())
})?;
let request_data = db::get_authorization_request(&state.db, &request_uri)
.await?
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
if request_data.expires_at < Utc::now() {
db::delete_authorization_request(&state.db, &request_uri).await?;
return Err(OAuthError::InvalidRequest("request_uri has expired".to_string()));
}
Ok(Json(AuthorizeResponse {
client_id: request_data.parameters.client_id.clone(),
client_name: None,
scope: request_data.parameters.scope.clone(),
redirect_uri: request_data.parameters.redirect_uri.clone(),
state: request_data.parameters.state.clone(),
login_hint: request_data.parameters.login_hint.clone(),
}))
}
pub async fn authorize_post(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<AuthorizeSubmit>,
) -> Result<Response, OAuthError> {
let request_data = db::get_authorization_request(&state.db, &form.request_uri)
.await?
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
if request_data.expires_at < Utc::now() {
db::delete_authorization_request(&state.db, &form.request_uri).await?;
return Err(OAuthError::InvalidRequest("request_uri has expired".to_string()));
}
let user = sqlx::query!(
r#"
SELECT did, password_hash, deactivated_at, takedown_ref
FROM users
WHERE handle = $1 OR email = $1
"#,
form.username
)
.fetch_optional(&state.db)
.await
.map_err(|e| OAuthError::ServerError(e.to_string()))?
.ok_or_else(|| OAuthError::AccessDenied("Invalid credentials".to_string()))?;
if user.deactivated_at.is_some() {
return Err(OAuthError::AccessDenied("Account is deactivated".to_string()));
}
if user.takedown_ref.is_some() {
return Err(OAuthError::AccessDenied("Account is taken down".to_string()));
}
let password_valid = bcrypt::verify(&form.password, &user.password_hash)
.map_err(|_| OAuthError::ServerError("Password verification failed".to_string()))?;
if !password_valid {
return Err(OAuthError::AccessDenied("Invalid credentials".to_string()));
}
let code = Code::generate();
let mut device_id: Option<String> = None;
if form.remember_device {
let new_device_id = DeviceId::generate();
let device_data = DeviceData {
session_id: SessionId::generate().0,
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers),
last_seen_at: Utc::now(),
};
db::create_device(&state.db, &new_device_id.0, &device_data).await?;
db::upsert_account_device(&state.db, &user.did, &new_device_id.0).await?;
device_id = Some(new_device_id.0);
}
db::update_authorization_request(
&state.db,
&form.request_uri,
&user.did,
device_id.as_deref(),
&code.0,
)
.await?;
let redirect_uri = &request_data.parameters.redirect_uri;
let mut redirect_url = redirect_uri.to_string();
let separator = if redirect_url.contains('?') { '&' } else { '?' };
redirect_url.push(separator);
redirect_url.push_str(&format!("code={}", url_encode(&code.0)));
if let Some(state) = &request_data.parameters.state {
redirect_url.push_str(&format!("&state={}", url_encode(state)));
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname))));
Ok(Redirect::temporary(&redirect_url).into_response())
}
#[derive(Debug, Serialize)]
pub struct AuthorizeDenyResponse {
pub error: String,
pub error_description: String,
}
pub async fn authorize_deny(
State(state): State<AppState>,
Form(form): Form<AuthorizeDenyForm>,
) -> Result<Response, OAuthError> {
let request_data = db::get_authorization_request(&state.db, &form.request_uri)
.await?
.ok_or_else(|| OAuthError::InvalidRequest("Invalid request_uri".to_string()))?;
db::delete_authorization_request(&state.db, &form.request_uri).await?;
let redirect_uri = &request_data.parameters.redirect_uri;
let mut redirect_url = redirect_uri.to_string();
let separator = if redirect_url.contains('?') { '&' } else { '?' };
redirect_url.push(separator);
redirect_url.push_str("error=access_denied");
redirect_url.push_str("&error_description=User%20denied%20the%20request");
if let Some(state) = &request_data.parameters.state {
redirect_url.push_str(&format!("&state={}", url_encode(state)));
}
Ok(Redirect::temporary(&redirect_url).into_response())
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeDenyForm {
pub request_uri: String,
}
+124
View File
@@ -0,0 +1,124 @@
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use crate::oauth::jwks::{JwkSet, create_jwk_set};
#[derive(Debug, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
pub authorization_servers: Vec<String>,
pub bearer_methods_supported: Vec<String>,
pub scopes_supported: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_documentation: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizationServerMetadata {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
pub response_types_supported: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_modes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_types_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge_methods_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pushed_authorization_request_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require_pushed_authorization_requests: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorization_response_iss_parameter_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revocation_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub introspection_endpoint: Option<String>,
}
pub async fn oauth_protected_resource(
State(_state): State<AppState>,
) -> Json<ProtectedResourceMetadata> {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let public_url = format!("https://{}", pds_hostname);
Json(ProtectedResourceMetadata {
resource: public_url.clone(),
authorization_servers: vec![public_url],
bearer_methods_supported: vec!["header".to_string()],
scopes_supported: vec![],
resource_documentation: Some("https://atproto.com".to_string()),
})
}
pub async fn oauth_authorization_server(
State(_state): State<AppState>,
) -> Json<AuthorizationServerMetadata> {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let issuer = format!("https://{}", pds_hostname);
Json(AuthorizationServerMetadata {
issuer: issuer.clone(),
authorization_endpoint: format!("{}/oauth/authorize", issuer),
token_endpoint: format!("{}/oauth/token", issuer),
jwks_uri: format!("{}/oauth/jwks", issuer),
registration_endpoint: None,
scopes_supported: Some(vec![
"atproto".to_string(),
"transition:generic".to_string(),
"transition:chat.bsky".to_string(),
]),
response_types_supported: vec!["code".to_string()],
response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]),
grant_types_supported: Some(vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
]),
token_endpoint_auth_methods_supported: Some(vec![
"none".to_string(),
"private_key_jwt".to_string(),
]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
pushed_authorization_request_endpoint: Some(format!("{}/oauth/par", issuer)),
require_pushed_authorization_requests: Some(true),
dpop_signing_alg_values_supported: Some(vec![
"ES256".to_string(),
"ES384".to_string(),
"ES512".to_string(),
"EdDSA".to_string(),
]),
authorization_response_iss_parameter_supported: Some(true),
revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)),
introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)),
})
}
pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> {
use crate::config::AuthConfig;
use crate::oauth::jwks::Jwk;
let config = AuthConfig::get();
let server_key = Jwk {
kty: "EC".to_string(),
key_use: Some("sig".to_string()),
kid: Some(config.signing_key_id.clone()),
alg: Some("ES256".to_string()),
crv: Some("P-256".to_string()),
x: Some(config.signing_key_x.clone()),
y: Some(config.signing_key_y.clone()),
};
Json(create_jwk_set(vec![server_key]))
}
+9
View File
@@ -0,0 +1,9 @@
pub mod metadata;
pub mod par;
pub mod authorize;
pub mod token;
pub use metadata::*;
pub use par::*;
pub use authorize::*;
pub use token::*;

Some files were not shown because too many files have changed in this diff Show More