mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-16 22:36:07 +00:00
Delegated accounts
This commit is contained in:
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did\n FROM oauth_authorization_request\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,8 +56,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3"
|
||||
"hash": "00cb951e3b8fcb33fd16a4f1ebfc1a6298c7068d891e0c67816e3db077953736"
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at,\n (u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as \"is_active!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1 AND d.revoked_at IS NULL\n ORDER BY d.granted_at DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "granted_scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "granted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_active!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "03e943475fd0af07d3e1ed5c14276c7841af9fc59076bd4017742844a91d29a1"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE oauth_authorization_request\n SET controller_did = $2\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "045ba5a6ab497737d09367f57df825f7945bb317b76b770ef68aa3f53df284a2"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 previous_refresh_token = $1 AND rotated_at > $2\n ",
|
||||
"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, controller_did\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -72,6 +72,11 @@
|
||||
"ordinal": 13,
|
||||
"name": "scope",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -94,8 +99,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913"
|
||||
"hash": "06c00269b11c250e85bde385e18ae8df6b1cc122f584105a8ea98861ff89e1b9"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"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, controller_did\n FROM oauth_token\n WHERE current_refresh_token = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -72,6 +72,11 @@
|
||||
"ordinal": 13,
|
||||
"name": "scope",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -93,8 +98,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95"
|
||||
"hash": "09cc26fbdc2d210146dccc3f9d1e6e82814596eadfd20d814e9f0d3f615127a8"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by_controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b"
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n delegated_did,\n actor_did,\n controller_did,\n action_type as \"action_type: DelegationActionType\",\n action_details,\n ip_address,\n user_agent,\n created_at\n FROM delegation_audit_log\n WHERE controller_did = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "delegated_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "actor_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "action_type: DelegationActionType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delegation_action_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"grant_created",
|
||||
"grant_revoked",
|
||||
"scopes_modified",
|
||||
"token_issued",
|
||||
"repo_write",
|
||||
"blob_upload",
|
||||
"account_action"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "action_details",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_agent",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1f44c06434b913554e26ad1e2674c56701f43fe12907594325e885c6f256045e"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT COUNT(*) as \"count!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "33d3ad8e4668b029a3cccfac6dda6d4612e248886fd6290aa47253c6bb325c45"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3781704482d019cbc5811ceab0ff26749d8fca1b13dfa7b2b2c42273ebb5beed"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as \"count!\" FROM delegation_audit_log WHERE delegated_did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "38154ef1114e42ff2718ab5aa10a653f32d097976d2c4881676d27454ad1c2e5"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT account_type::text = 'delegated' as \"is_delegated!\" FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_delegated!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "40d42ed61a77074b298539e492d8fb6493174a7c49324e6f4f20b68bc30e95f4"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM account_delegations\n WHERE controller_did = $1 AND revoked_at IS NULL\n ) as \"exists!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "49e7d9a260209502aa79ef9f83bed78ec38b6f7c068fdf8433696082cfad91a8"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n DELETE FROM oauth_authorization_request\n WHERE code = $1\n RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -51,8 +56,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4"
|
||||
"hash": "747a6f19cf9d6e971d359d8d269fe2e50e2ed3682c0bb746e7b2fbc5e493027a"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE oauth_authorization_request\n SET did = $2\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7b4977eb51715a385cb00ee88dd3395fa28f9c0d2edc3dc1670c415ad983394f"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as \"exists!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8023c93fa18592cc5ebde7ae856effa70ef57e2801ecba999512f1b12000de9c"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT st.id, st.did, st.scope, 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()\n FOR UPDATE OF st",
|
||||
"query": "SELECT st.id, st.did, st.scope, st.controller_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()\n FOR UPDATE OF st",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,11 +20,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
@@ -38,9 +43,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883"
|
||||
"hash": "80c029ff08ef3f7d19054fca573dee4037f38b7a7bf1473a0cae7887350de556"
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, password_hash, deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deactivated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "takedown_ref",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "90f46f595f418c306a9229e5c5379bb6e1a3f121a346dce565e6d3075b058f01"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)\n VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9182105a0f3cd4659e7c4bedb13c5670121fb25c351aa427a6b42a632c95e249"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"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, controller_did\n FROM oauth_token\n WHERE token_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"ordinal": 12,
|
||||
"name": "scope",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -87,8 +92,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765"
|
||||
"hash": "a886fcf853e54f3be88143b373f58a7fbf0881d19649c036660ef6cf52d14fa2"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM account_delegations\n WHERE delegated_did = $1 AND revoked_at IS NULL\n ) as \"exists!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a9bf34b436e0eecf3489cdabe9286b4ecb18905dc66e86a4084081f943b71d4c"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"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, controller_did\n FROM oauth_token\n WHERE did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"ordinal": 12,
|
||||
"name": "scope",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -87,8 +92,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8"
|
||||
"hash": "b474591bf3bd9359bd0d8af186f090a32c79a940771168d67160f3190da2eea4"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
"query": "SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
"ordinal": 3,
|
||||
"name": "scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_by_controller_did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -33,8 +38,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb"
|
||||
"hash": "bbd387655387724e97f819e78033682edffbd2463a65b2bb48ca73794dafdbcc"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE account_delegations\n SET granted_scopes = $1\n WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bc0d078c738c6ebdaa19446608e96727c0f2f227e9fbcb06172e5c444bea6347"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM app_passwords\n WHERE user_id = (SELECT id FROM users WHERE did = $1)\n AND created_by_controller_did = $2\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c32aa6a95bf31d41eb2c60b97e6a90ae6a3ff84cc48e52459bc8657a7ce36413"
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,10 +10,11 @@
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40"
|
||||
"hash": "c3a0d5bbac7b0d33f79e61fd9790cd737b62628f5597489066228dd30af42c82"
|
||||
}
|
||||
+5
-5
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
|
||||
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password_hash",
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "scopes",
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -24,5 +24,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9"
|
||||
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -79,6 +79,11 @@
|
||||
"ordinal": 12,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "account_type!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -99,8 +104,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f"
|
||||
"hash": "c7353563d686b963723fb049b3a3f9f0162afef510b91926e29cf74ec05d25c6"
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"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, controller_did)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -23,6 +23,7 @@
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -30,5 +31,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0"
|
||||
"hash": "cd3bc8199c3f9285f214ef091ad52dc881a19cf19fe27a2ba1f383ffb8e3fc0d"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at\n FROM account_delegations d\n JOIN users u ON u.did = d.delegated_did\n WHERE d.controller_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ORDER BY d.granted_at DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "granted_scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "granted_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ceb51f40c33d99fc17c37d7cb685152c5f9d447bcbbedd47e8fb34d358e7669a"
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at,\n true as \"is_active!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ORDER BY d.granted_at DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "granted_scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "granted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_active!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d8e33a911d741e636d1f0efd81f8fc528d9af2716887d0d72b70ca7c7d7eb11a"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, delegated_did, controller_did, granted_scopes,\n granted_at, granted_by, revoked_at, revoked_by\n FROM account_delegations\n WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "delegated_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "granted_scopes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "granted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "granted_by",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "revoked_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "revoked_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ddd3e85a88d9a782c54bdc33072747dd5db70cf76432e50635e22343092eadeb"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE account_delegations\n SET revoked_at = NOW(), revoked_by = $1\n WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eb7fe20b8124f1e9ba0f1ba74a4640cae40d6d1b1ddd503080cb75385246d7e1"
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n delegated_did,\n actor_did,\n controller_did,\n action_type as \"action_type: DelegationActionType\",\n action_details,\n ip_address,\n user_agent,\n created_at\n FROM delegation_audit_log\n WHERE delegated_did = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "delegated_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "actor_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "action_type: DelegationActionType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delegation_action_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"grant_created",
|
||||
"grant_revoked",
|
||||
"scopes_modified",
|
||||
"token_issued",
|
||||
"repo_write",
|
||||
"blob_upload",
|
||||
"account_action"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "action_details",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_agent",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f18172e06c03978fb56a4e3acc9a926bdd0414f7883539113f7ec2d640ce184a"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO delegation_audit_log\n (delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "delegation_action_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"grant_created",
|
||||
"grant_revoked",
|
||||
"scopes_modified",
|
||||
"token_issued",
|
||||
"repo_write",
|
||||
"blob_upload",
|
||||
"account_action"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f3cd43a21db350887127cd7e0cd24e95a70571cc5e9b2278dda49a2538d794ae"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f631186890dc38141299d8ecf6feda13c4ab8bd6e3834c64b2cd508305bed3aa"
|
||||
}
|
||||
@@ -14,7 +14,7 @@ Another excellent PDS is [Cocoon](https://github.com/haileyok/cocoon), written i
|
||||
|
||||
This software isn't an afterthought by a company with limited resources.
|
||||
|
||||
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
|
||||
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
|
||||
|
||||
The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor.
|
||||
|
||||
|
||||
@@ -2,23 +2,6 @@
|
||||
|
||||
## Active development
|
||||
|
||||
### Delegated accounts
|
||||
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
|
||||
|
||||
- [ ] Account type flag in actors table (personal | delegated)
|
||||
- [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at)
|
||||
- [ ] Detect delegated account during authorize flow
|
||||
- [ ] Redirect to "authenticate as controller" instead of password prompt
|
||||
- [ ] Validate controller has delegation grant for this account
|
||||
- [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes)
|
||||
- [ ] Token includes act_as claim indicating delegation
|
||||
- [ ] Define standard scope sets (owner, admin, editor, viewer)
|
||||
- [ ] Create delegated account flow (no password, must add initial controller)
|
||||
- [ ] Controller management page (add/remove controllers, modify scopes)
|
||||
- [ ] "Act as" account switcher for users with delegation grants
|
||||
- [ ] Log all actions with both actor DID and controller DID
|
||||
- [ ] Audit log view for delegated account owners
|
||||
|
||||
### Migration tool
|
||||
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
|
||||
|
||||
@@ -85,3 +68,5 @@ Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family
|
||||
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
|
||||
|
||||
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
|
||||
|
||||
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
|
||||
|
||||
@@ -25,9 +25,13 @@
|
||||
import OAuth2FA from './routes/OAuth2FA.svelte'
|
||||
import OAuthTotp from './routes/OAuthTotp.svelte'
|
||||
import OAuthPasskey from './routes/OAuthPasskey.svelte'
|
||||
import OAuthDelegation from './routes/OAuthDelegation.svelte'
|
||||
import OAuthError from './routes/OAuthError.svelte'
|
||||
import Security from './routes/Security.svelte'
|
||||
import TrustedDevices from './routes/TrustedDevices.svelte'
|
||||
import Controllers from './routes/Controllers.svelte'
|
||||
import DelegationAudit from './routes/DelegationAudit.svelte'
|
||||
import ActAs from './routes/ActAs.svelte'
|
||||
import Home from './routes/Home.svelte'
|
||||
|
||||
initI18n()
|
||||
@@ -95,12 +99,20 @@
|
||||
return OAuthTotp
|
||||
case '/oauth/passkey':
|
||||
return OAuthPasskey
|
||||
case '/oauth/delegation':
|
||||
return OAuthDelegation
|
||||
case '/oauth/error':
|
||||
return OAuthError
|
||||
case '/security':
|
||||
return Security
|
||||
case '/trusted-devices':
|
||||
return TrustedDevices
|
||||
case '/controllers':
|
||||
return Controllers
|
||||
case '/delegation-audit':
|
||||
return DelegationAudit
|
||||
case '/act-as':
|
||||
return ActAs
|
||||
default:
|
||||
return Home
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface AppPassword {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
scopes?: string;
|
||||
createdByController?: string;
|
||||
}
|
||||
|
||||
export interface InviteCode {
|
||||
|
||||
@@ -44,20 +44,20 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const hash = await sha256(verifier);
|
||||
return base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
export function generateState(): string {
|
||||
return generateRandomString(32);
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
export function generateCodeVerifier(): string {
|
||||
return generateRandomString(32);
|
||||
}
|
||||
|
||||
function saveOAuthState(state: OAuthState): void {
|
||||
export function saveOAuthState(state: OAuthState): void {
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state.state);
|
||||
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"continue": "Continue",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "Full Access",
|
||||
"scopeReadOnly": "Read Only",
|
||||
"scopePostOnly": "Post Only",
|
||||
"scopeCustom": "Custom"
|
||||
"scopeCustom": "Custom",
|
||||
"byController": "By Controller"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Active Sessions",
|
||||
@@ -853,6 +855,121 @@
|
||||
"verifying": "Verifying...",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "Account Delegation",
|
||||
"loading": "Loading...",
|
||||
"controllers": "Controllers",
|
||||
"controllersDesc": "Accounts that can act on your behalf",
|
||||
"noControllers": "No controllers have been granted access to your account.",
|
||||
"inactive": "Inactive",
|
||||
"did": "DID",
|
||||
"granted": "Granted",
|
||||
"remove": "Remove",
|
||||
"removeConfirm": "Are you sure you want to remove this controller?",
|
||||
"cannotAddControllers": "You cannot add controllers because this account controls other accounts. An account can either have controllers or control other accounts, but not both.",
|
||||
"addController": "Add Controller",
|
||||
"controllerDid": "Controller DID",
|
||||
"accessLevel": "Access Level",
|
||||
"adding": "Adding...",
|
||||
"addControllerButton": "+ Add Controller",
|
||||
"controllerAdded": "Controller added successfully",
|
||||
"controllerRemoved": "Controller removed successfully",
|
||||
"failedToAddController": "Failed to add controller",
|
||||
"failedToRemoveController": "Failed to remove controller",
|
||||
"controlledAccounts": "Controlled Accounts",
|
||||
"controlledAccountsDesc": "Accounts you can act on behalf of",
|
||||
"noControlledAccounts": "You do not have access to any delegated accounts.",
|
||||
"actAs": "Act As",
|
||||
"cannotControlAccounts": "You cannot control other accounts because this account has controllers. An account can either have controllers or control other accounts, but not both.",
|
||||
"createDelegatedAccount": "Create Delegated Account",
|
||||
"handle": "Handle",
|
||||
"emailOptional": "Email (optional)",
|
||||
"yourAccessLevel": "Your Access Level",
|
||||
"creating": "Creating...",
|
||||
"createAccount": "Create Account",
|
||||
"createDelegatedAccountButton": "+ Create Delegated Account",
|
||||
"accountCreated": "Created delegated account: {handle}",
|
||||
"failedToCreateAccount": "Failed to create delegated account",
|
||||
"auditLog": "Audit Log",
|
||||
"auditLogDesc": "View all delegation activity",
|
||||
"viewAuditLog": "View Audit Log",
|
||||
"scopeOwner": "Owner",
|
||||
"scopeViewer": "Viewer",
|
||||
"scopeCustom": "Custom",
|
||||
"backToControllers": "Back to Controllers",
|
||||
"auditLogTitle": "Delegation Audit Log",
|
||||
"noActivity": "No delegation activity recorded.",
|
||||
"actor": "Actor",
|
||||
"controller": "Controller",
|
||||
"account": "Account",
|
||||
"details": "Details",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"showing": "Showing {start} - {end} of {total}",
|
||||
"refresh": "Refresh",
|
||||
"failedToLoadAuditLog": "Failed to load audit log",
|
||||
"actionGrantCreated": "Grant Created",
|
||||
"actionGrantRevoked": "Grant Revoked",
|
||||
"actionScopesModified": "Scopes Modified",
|
||||
"actionTokenIssued": "Token Issued",
|
||||
"actionRepoWrite": "Repository Write",
|
||||
"actionBlobUpload": "Blob Upload",
|
||||
"actionAccountAction": "Account Action"
|
||||
},
|
||||
"actAs": {
|
||||
"noAccountSpecified": "No account DID specified",
|
||||
"failedToVerify": "Failed to verify delegation access",
|
||||
"noAccess": "You do not have access to this account",
|
||||
"failedToInitiate": "Failed to initiate OAuth flow",
|
||||
"invalidResponse": "Invalid OAuth response",
|
||||
"failedError": "Failed to initiate act-as: {error}",
|
||||
"preparing": "Preparing to switch accounts...",
|
||||
"title": "Act As",
|
||||
"backToControllers": "Back to Controllers"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "Loading...",
|
||||
"title": "Delegated Account",
|
||||
"isDelegated": "{handle} is a delegated account.",
|
||||
"enterControllerHandle": "Sign in with your controller account to access this account.",
|
||||
"controllerHandle": "Controller handle",
|
||||
"handlePlaceholder": "handle.example.com",
|
||||
"checking": "Checking...",
|
||||
"controllerNotFound": "Account not found or you don't have access to this delegated account",
|
||||
"missingParams": "Missing delegation parameters",
|
||||
"missingInfo": "Missing required information",
|
||||
"passkeyCancelled": "Passkey authentication cancelled",
|
||||
"passkeyFailed": "Passkey authentication failed",
|
||||
"failedPasskeyStart": "Failed to start passkey login",
|
||||
"authFailed": "Authentication failed",
|
||||
"unexpectedResponse": "Unexpected response from server",
|
||||
"signInAsController": "Sign In as Controller",
|
||||
"authenticateAs": "Authenticate as {controller} to act on behalf of {delegated}",
|
||||
"useDifferentController": "Use a different controller",
|
||||
"signInWithPasskey": "Sign in with Passkey",
|
||||
"authenticating": "Authenticating...",
|
||||
"usePasskey": "Use Passkey",
|
||||
"or": "or",
|
||||
"password": "Password",
|
||||
"enterPassword": "Enter password",
|
||||
"rememberDevice": "Remember this device",
|
||||
"signingIn": "Signing in...",
|
||||
"signIn": "Sign In",
|
||||
"goBack": "Go Back",
|
||||
"unableToLoad": "Unable to load delegation info"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "Delegated Access",
|
||||
"actingAs": "Acting as",
|
||||
"controller": "Controller",
|
||||
"accessLevel": "Access Level",
|
||||
"readOnlyAccess": "Read-Only Access",
|
||||
"readOnlyDesc": "View public information only. No write access to this account.",
|
||||
"permissionsLimited": "Permissions Limited",
|
||||
"permissionsLimitedDesc": "Your actual permissions will be limited to your {level} access level, regardless of what the app requests.",
|
||||
"viewerLimitedDesc": "As a Viewer, you have read-only access. This app will not be able to create, update, or delete content on this account.",
|
||||
"editorLimitedDesc": "As an Editor, you can create and edit content but cannot manage account settings or security."
|
||||
},
|
||||
"verifyChannel": {
|
||||
"title": "Verify Channel",
|
||||
"subtitle": "Enter the verification code sent to your notification channel.",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "Peruuta",
|
||||
"back": "Takaisin",
|
||||
"done": "Valmis",
|
||||
"continue": "Jatka",
|
||||
"refresh": "Päivitä",
|
||||
"create": "Luo",
|
||||
"delete": "Poista",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "Täydet oikeudet",
|
||||
"scopeReadOnly": "Vain luku",
|
||||
"scopePostOnly": "Vain julkaisut",
|
||||
"scopeCustom": "Mukautettu"
|
||||
"scopeCustom": "Mukautettu",
|
||||
"byController": "Hallinnoijan luoma"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Aktiiviset istunnot",
|
||||
@@ -888,5 +890,120 @@
|
||||
"codeLabel": "Vahvistuskoodi",
|
||||
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat.",
|
||||
"verifyButton": "Vahvista"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "Tilin delegointi",
|
||||
"loading": "Ladataan...",
|
||||
"controllers": "Hallinnoijat",
|
||||
"controllersDesc": "Tilit, jotka voivat toimia puolestasi",
|
||||
"noControllers": "Tilillesi ei ole myönnetty hallinnoijia.",
|
||||
"inactive": "Ei käytössä",
|
||||
"did": "DID",
|
||||
"granted": "Myönnetty",
|
||||
"remove": "Poista",
|
||||
"removeConfirm": "Haluatko varmasti poistaa tämän hallinnoijan?",
|
||||
"cannotAddControllers": "Et voi lisätä hallinnoijia, koska tämä tili hallinnoi muita tilejä. Tili voi joko olla hallinnoija tai hallinnoidaan, mutta ei molempia.",
|
||||
"addController": "Lisää hallinnoija",
|
||||
"controllerDid": "Hallinnoijan DID",
|
||||
"accessLevel": "Käyttöoikeustaso",
|
||||
"adding": "Lisätään...",
|
||||
"addControllerButton": "+ Lisää hallinnoija",
|
||||
"controllerAdded": "Hallinnoija lisätty",
|
||||
"controllerRemoved": "Hallinnoija poistettu",
|
||||
"failedToAddController": "Hallinnoijan lisääminen epäonnistui",
|
||||
"failedToRemoveController": "Hallinnoijan poistaminen epäonnistui",
|
||||
"controlledAccounts": "Hallinnoidut tilit",
|
||||
"controlledAccountsDesc": "Tilit, joiden puolesta voit toimia",
|
||||
"noControlledAccounts": "Sinulla ei ole pääsyä delegoituihin tileihin.",
|
||||
"actAs": "Toimi käyttäjänä",
|
||||
"cannotControlAccounts": "Et voi hallinnoida muita tilejä, koska tällä tilillä on hallinnoijia. Tili voi joko olla hallinnoija tai hallinnoidaan, mutta ei molempia.",
|
||||
"createDelegatedAccount": "Luo delegoitu tili",
|
||||
"handle": "Käyttäjänimi",
|
||||
"emailOptional": "Sähköposti (valinnainen)",
|
||||
"yourAccessLevel": "Käyttöoikeustasosi",
|
||||
"creating": "Luodaan...",
|
||||
"createAccount": "Luo tili",
|
||||
"createDelegatedAccountButton": "+ Luo delegoitu tili",
|
||||
"accountCreated": "Delegoitu tili luotu: {handle}",
|
||||
"failedToCreateAccount": "Delegoidun tilin luominen epäonnistui",
|
||||
"auditLog": "Tapahtumaloki",
|
||||
"auditLogDesc": "Näytä kaikki delegointitoiminta",
|
||||
"viewAuditLog": "Näytä tapahtumaloki",
|
||||
"scopeOwner": "Omistaja",
|
||||
"scopeViewer": "Katsoja",
|
||||
"scopeCustom": "Mukautettu",
|
||||
"backToControllers": "Takaisin hallinnoijiin",
|
||||
"auditLogTitle": "Delegoinnin tapahtumaloki",
|
||||
"noActivity": "Delegointitoimintaa ei ole tallennettu.",
|
||||
"actor": "Toimija",
|
||||
"controller": "Hallinnoija",
|
||||
"account": "Tili",
|
||||
"details": "Tiedot",
|
||||
"previous": "Edellinen",
|
||||
"next": "Seuraava",
|
||||
"showing": "Näytetään {start} - {end} / {total}",
|
||||
"refresh": "Päivitä",
|
||||
"failedToLoadAuditLog": "Tapahtumalokin lataaminen epäonnistui",
|
||||
"actionGrantCreated": "Oikeus luotu",
|
||||
"actionGrantRevoked": "Oikeus peruttu",
|
||||
"actionScopesModified": "Oikeuksia muokattu",
|
||||
"actionTokenIssued": "Token myönnetty",
|
||||
"actionRepoWrite": "Tietovaraston kirjoitus",
|
||||
"actionBlobUpload": "Tiedoston lataus",
|
||||
"actionAccountAction": "Tilitoiminto"
|
||||
},
|
||||
"actAs": {
|
||||
"noAccountSpecified": "Tilin DID:tä ei määritetty",
|
||||
"failedToVerify": "Delegointioikeuden tarkistus epäonnistui",
|
||||
"noAccess": "Sinulla ei ole pääsyä tähän tiliin",
|
||||
"failedToInitiate": "OAuth-kirjautumisen aloitus epäonnistui",
|
||||
"invalidResponse": "Virheellinen OAuth-vastaus",
|
||||
"failedError": "Toiminto epäonnistui: {error}",
|
||||
"preparing": "Valmistellaan tilin vaihtoa...",
|
||||
"title": "Toimi käyttäjänä",
|
||||
"backToControllers": "Takaisin hallinnoijiin"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "Ladataan...",
|
||||
"title": "Delegoitu tili",
|
||||
"isDelegated": "{handle} on delegoitu tili.",
|
||||
"enterControllerHandle": "Kirjaudu hallinnoijatililläsi päästäksesi tähän tiliin.",
|
||||
"controllerHandle": "Hallinnoijan käyttäjätunnus",
|
||||
"handlePlaceholder": "tunnus.esimerkki.fi",
|
||||
"checking": "Tarkistetaan...",
|
||||
"controllerNotFound": "Tiliä ei löytynyt tai sinulla ei ole pääsyä tähän delegoituun tiliin",
|
||||
"missingParams": "Delegointiparametrit puuttuvat",
|
||||
"missingInfo": "Vaaditut tiedot puuttuvat",
|
||||
"passkeyCancelled": "Pääsyavaintunnistautuminen peruutettu",
|
||||
"passkeyFailed": "Pääsyavaintunnistautuminen epäonnistui",
|
||||
"failedPasskeyStart": "Pääsyavainkirjautumisen aloitus epäonnistui",
|
||||
"authFailed": "Tunnistautuminen epäonnistui",
|
||||
"unexpectedResponse": "Odottamaton vastaus palvelimelta",
|
||||
"signInAsController": "Kirjaudu hallinnoijana",
|
||||
"authenticateAs": "Tunnistaudu käyttäjänä {controller} toimiaksesi käyttäjän {delegated} puolesta",
|
||||
"useDifferentController": "Käytä toista hallinnoijaa",
|
||||
"signInWithPasskey": "Kirjaudu pääsyavaimella",
|
||||
"authenticating": "Tunnistaudutaan...",
|
||||
"usePasskey": "Käytä pääsyavainta",
|
||||
"or": "tai",
|
||||
"password": "Salasana",
|
||||
"enterPassword": "Syötä salasana",
|
||||
"rememberDevice": "Muista tämä laite",
|
||||
"signingIn": "Kirjaudutaan...",
|
||||
"signIn": "Kirjaudu",
|
||||
"goBack": "Palaa takaisin",
|
||||
"unableToLoad": "Delegointitietoja ei voitu ladata"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "Delegoitu pääsy",
|
||||
"actingAs": "Toimii käyttäjänä",
|
||||
"controller": "Hallinnoija",
|
||||
"accessLevel": "Käyttöoikeustaso",
|
||||
"readOnlyAccess": "Vain luku -oikeus",
|
||||
"readOnlyDesc": "Näytä vain julkiset tiedot. Ei kirjoitusoikeutta tähän tiliin.",
|
||||
"permissionsLimited": "Oikeudet rajoitettu",
|
||||
"permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.",
|
||||
"viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.",
|
||||
"editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "キャンセル",
|
||||
"back": "戻る",
|
||||
"done": "完了",
|
||||
"continue": "続行",
|
||||
"refresh": "更新",
|
||||
"create": "作成",
|
||||
"delete": "削除",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "フルアクセス",
|
||||
"scopeReadOnly": "読み取り専用",
|
||||
"scopePostOnly": "投稿のみ",
|
||||
"scopeCustom": "カスタム"
|
||||
"scopeCustom": "カスタム",
|
||||
"byController": "管理者作成"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "アクティブセッション",
|
||||
@@ -888,5 +890,142 @@
|
||||
"codeLabel": "認証コード",
|
||||
"codeHelp": "メッセージからハイフンを含む完全なコードをコピーしてください。",
|
||||
"verifyButton": "認証"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "アカウント委任",
|
||||
"controllers": "コントローラー",
|
||||
"controllersDescription": "コントローラーはあなたのアカウントの管理者として行動できます。あなたが許可した操作を実行し、あなたの代わりに投稿を作成し、リポジトリを変更できます。",
|
||||
"controlledAccounts": "管理アカウント",
|
||||
"controlledAccountsDescription": "これらはあなたがコントローラーとして追加されているアカウントです。これらのアカウントで許可されたアクションを実行できます。",
|
||||
"noControllers": "コントローラーはまだいません",
|
||||
"noControlledAccounts": "管理アカウントはありません",
|
||||
"addController": "コントローラーを追加",
|
||||
"revokeAccess": "アクセスを取り消す",
|
||||
"revokeConfirm": "このコントローラーのアクセスを取り消しますか?あなたのアカウントで操作できなくなります。",
|
||||
"handle": "ハンドル",
|
||||
"handlePlaceholder": "@user.bsky.social",
|
||||
"did": "DID",
|
||||
"didPlaceholder": "did:plc:...",
|
||||
"scopes": "権限レベル",
|
||||
"scopeOwner": "オーナー",
|
||||
"scopeOwnerDesc": "完全な管理(すべてのアクションを実行可能)",
|
||||
"scopeAdmin": "管理者",
|
||||
"scopeAdminDesc": "投稿、アプリパスワード、設定の管理",
|
||||
"scopeEditor": "編集者",
|
||||
"scopeEditorDesc": "投稿、いいね、フォローの作成・管理",
|
||||
"scopeViewer": "閲覧者",
|
||||
"scopeViewerDesc": "リポジトリと設定の読み取り専用アクセス",
|
||||
"scopeCustom": "カスタム",
|
||||
"scopeCustomDesc": "個別の権限を選択",
|
||||
"grantedAt": "許可日時",
|
||||
"expiresAt": "有効期限",
|
||||
"noExpiration": "無期限",
|
||||
"actAs": "として行動",
|
||||
"auditLog": "監査ログ",
|
||||
"auditLogTitle": "委任監査ログ",
|
||||
"backToControllers": "← コントローラーに戻る",
|
||||
"loading": "読み込み中...",
|
||||
"noActivity": "アクティビティはまだありません",
|
||||
"actor": "アクター",
|
||||
"controller": "コントローラー",
|
||||
"account": "アカウント",
|
||||
"details": "詳細",
|
||||
"actionGrantCreated": "許可作成",
|
||||
"actionGrantRevoked": "許可取り消し",
|
||||
"actionScopesModified": "権限変更",
|
||||
"actionTokenIssued": "トークン発行",
|
||||
"actionRepoWrite": "リポジトリ書き込み",
|
||||
"actionBlobUpload": "Blobアップロード",
|
||||
"actionAccountAction": "アカウントアクション",
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
"showing": "{start}~{end} / {total}件",
|
||||
"refresh": "更新",
|
||||
"failedToLoadAuditLog": "監査ログの読み込みに失敗しました",
|
||||
"addControllerTitle": "コントローラーを追加",
|
||||
"addControllerDescription": "このアカウントに対して指定した権限で操作できるユーザーを追加します。",
|
||||
"controllerIdentifier": "コントローラーのハンドルまたはDID",
|
||||
"selectScopes": "権限レベルを選択",
|
||||
"add": "追加",
|
||||
"adding": "追加中...",
|
||||
"cancel": "キャンセル",
|
||||
"accessLevel": "アクセスレベル",
|
||||
"addControllerButton": "+ コントローラーを追加",
|
||||
"auditLogDesc": "すべての委任アクティビティを表示",
|
||||
"cannotAddControllers": "他のアカウントを管理しているため、コントローラーを追加できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。",
|
||||
"cannotControlAccounts": "このアカウントにはコントローラーがいるため、他のアカウントを管理できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。",
|
||||
"controlledAccountsDesc": "あなたが代わりに操作できるアカウント",
|
||||
"controllerAdded": "コントローラーを追加しました",
|
||||
"controllerDid": "コントローラーDID",
|
||||
"controllerRemoved": "コントローラーを削除しました",
|
||||
"controllersDesc": "あなたの代わりに操作できるアカウント",
|
||||
"createAccount": "アカウントを作成",
|
||||
"createDelegatedAccount": "委任アカウントを作成",
|
||||
"createDelegatedAccountButton": "+ 委任アカウントを作成",
|
||||
"creating": "作成中...",
|
||||
"emailOptional": "メール(任意)",
|
||||
"failedToAddController": "コントローラーの追加に失敗しました",
|
||||
"failedToCreateAccount": "委任アカウントの作成に失敗しました",
|
||||
"failedToRemoveController": "コントローラーの削除に失敗しました",
|
||||
"granted": "許可日",
|
||||
"inactive": "非アクティブ",
|
||||
"remove": "削除",
|
||||
"removeConfirm": "このコントローラーを削除しますか?",
|
||||
"viewAuditLog": "監査ログを表示",
|
||||
"yourAccessLevel": "あなたのアクセスレベル"
|
||||
},
|
||||
"actAs": {
|
||||
"title": "として行動",
|
||||
"noAccountSpecified": "アカウントDIDが指定されていません",
|
||||
"failedToVerify": "アカウントへのアクセスを確認できませんでした",
|
||||
"noAccess": "このアカウントへのアクセス権がありません",
|
||||
"failedToInitiate": "認証の開始に失敗しました",
|
||||
"invalidResponse": "サーバーからの応答が無効です",
|
||||
"failedError": "失敗しました: {error}",
|
||||
"preparing": "委任アカウントへのログインを準備中...",
|
||||
"backToControllers": "コントローラーに戻る"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "読み込み中...",
|
||||
"title": "委任アカウント",
|
||||
"isDelegated": "{handle} は委任アカウントです。",
|
||||
"enterControllerHandle": "このアカウントにアクセスするには、コントローラーアカウントでサインインしてください。",
|
||||
"controllerHandle": "コントローラーハンドル",
|
||||
"handlePlaceholder": "handle.example.com",
|
||||
"checking": "確認中...",
|
||||
"controllerNotFound": "アカウントが見つからないか、この委任アカウントへのアクセス権がありません",
|
||||
"missingParams": "委任パラメータが見つかりません",
|
||||
"missingInfo": "必要な情報がありません",
|
||||
"passkeyCancelled": "パスキー認証がキャンセルされました",
|
||||
"passkeyFailed": "パスキー認証に失敗しました",
|
||||
"failedPasskeyStart": "パスキーログインの開始に失敗しました",
|
||||
"authFailed": "認証に失敗しました",
|
||||
"unexpectedResponse": "サーバーから予期しない応答がありました",
|
||||
"signInAsController": "コントローラーとしてサインイン",
|
||||
"authenticateAs": "{controller} として認証して {delegated} の代わりに操作します",
|
||||
"useDifferentController": "別のコントローラーを使用",
|
||||
"signInWithPasskey": "パスキーでサインイン",
|
||||
"authenticating": "認証中...",
|
||||
"usePasskey": "パスキーを使用",
|
||||
"or": "または",
|
||||
"password": "パスワード",
|
||||
"enterPassword": "パスワードを入力",
|
||||
"rememberDevice": "このデバイスを記憶する",
|
||||
"signingIn": "サインイン中...",
|
||||
"signIn": "サインイン",
|
||||
"goBack": "戻る",
|
||||
"unableToLoad": "委任情報を読み込めませんでした"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "委任アクセス",
|
||||
"actingAs": "次として行動中",
|
||||
"controller": "コントローラー",
|
||||
"accessLevel": "アクセスレベル",
|
||||
"readOnlyAccess": "読み取り専用アクセス",
|
||||
"readOnlyDesc": "公開情報のみ閲覧可能。このアカウントへの書き込みアクセスはありません。",
|
||||
"permissionsLimited": "権限が制限されています",
|
||||
"permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。",
|
||||
"viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。",
|
||||
"editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "취소",
|
||||
"back": "뒤로",
|
||||
"done": "완료",
|
||||
"continue": "계속",
|
||||
"refresh": "새로고침",
|
||||
"create": "생성",
|
||||
"delete": "삭제",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "전체 권한",
|
||||
"scopeReadOnly": "읽기 전용",
|
||||
"scopePostOnly": "게시만 가능",
|
||||
"scopeCustom": "사용자 지정"
|
||||
"scopeCustom": "사용자 지정",
|
||||
"byController": "컨트롤러 생성"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "활성 세션",
|
||||
@@ -888,5 +890,142 @@
|
||||
"codeLabel": "인증 코드",
|
||||
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요.",
|
||||
"verifyButton": "인증"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "계정 위임",
|
||||
"controllers": "컨트롤러",
|
||||
"controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.",
|
||||
"controlledAccounts": "관리 계정",
|
||||
"controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.",
|
||||
"noControllers": "아직 컨트롤러가 없습니다",
|
||||
"noControlledAccounts": "관리 계정이 없습니다",
|
||||
"addController": "컨트롤러 추가",
|
||||
"revokeAccess": "액세스 취소",
|
||||
"revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.",
|
||||
"handle": "핸들",
|
||||
"handlePlaceholder": "@user.bsky.social",
|
||||
"did": "DID",
|
||||
"didPlaceholder": "did:plc:...",
|
||||
"scopes": "권한 수준",
|
||||
"scopeOwner": "소유자",
|
||||
"scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)",
|
||||
"scopeAdmin": "관리자",
|
||||
"scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리",
|
||||
"scopeEditor": "편집자",
|
||||
"scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리",
|
||||
"scopeViewer": "뷰어",
|
||||
"scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스",
|
||||
"scopeCustom": "사용자 정의",
|
||||
"scopeCustomDesc": "개별 권한 선택",
|
||||
"grantedAt": "허용 일시",
|
||||
"expiresAt": "만료",
|
||||
"noExpiration": "무기한",
|
||||
"actAs": "로 활동",
|
||||
"auditLog": "감사 로그",
|
||||
"auditLogTitle": "위임 감사 로그",
|
||||
"backToControllers": "← 컨트롤러로 돌아가기",
|
||||
"loading": "로딩 중...",
|
||||
"noActivity": "아직 활동이 없습니다",
|
||||
"actor": "액터",
|
||||
"controller": "컨트롤러",
|
||||
"account": "계정",
|
||||
"details": "세부정보",
|
||||
"actionGrantCreated": "권한 생성",
|
||||
"actionGrantRevoked": "권한 취소",
|
||||
"actionScopesModified": "권한 수정",
|
||||
"actionTokenIssued": "토큰 발급",
|
||||
"actionRepoWrite": "저장소 쓰기",
|
||||
"actionBlobUpload": "Blob 업로드",
|
||||
"actionAccountAction": "계정 작업",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"showing": "{start}~{end} / {total}개",
|
||||
"refresh": "새로고침",
|
||||
"failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다",
|
||||
"addControllerTitle": "컨트롤러 추가",
|
||||
"addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.",
|
||||
"controllerIdentifier": "컨트롤러 핸들 또는 DID",
|
||||
"selectScopes": "권한 수준 선택",
|
||||
"add": "추가",
|
||||
"adding": "추가 중...",
|
||||
"cancel": "취소",
|
||||
"accessLevel": "액세스 수준",
|
||||
"addControllerButton": "+ 컨트롤러 추가",
|
||||
"auditLogDesc": "모든 위임 활동 보기",
|
||||
"cannotAddControllers": "다른 계정을 관리하고 있어 컨트롤러를 추가할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.",
|
||||
"cannotControlAccounts": "이 계정에 컨트롤러가 있어 다른 계정을 관리할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.",
|
||||
"controlledAccountsDesc": "귀하가 대신 작업할 수 있는 계정",
|
||||
"controllerAdded": "컨트롤러가 추가되었습니다",
|
||||
"controllerDid": "컨트롤러 DID",
|
||||
"controllerRemoved": "컨트롤러가 제거되었습니다",
|
||||
"controllersDesc": "귀하를 대신하여 작업할 수 있는 계정",
|
||||
"createAccount": "계정 생성",
|
||||
"createDelegatedAccount": "위임 계정 생성",
|
||||
"createDelegatedAccountButton": "+ 위임 계정 생성",
|
||||
"creating": "생성 중...",
|
||||
"emailOptional": "이메일 (선택사항)",
|
||||
"failedToAddController": "컨트롤러 추가에 실패했습니다",
|
||||
"failedToCreateAccount": "위임 계정 생성에 실패했습니다",
|
||||
"failedToRemoveController": "컨트롤러 제거에 실패했습니다",
|
||||
"granted": "허용일",
|
||||
"inactive": "비활성",
|
||||
"remove": "제거",
|
||||
"removeConfirm": "이 컨트롤러를 제거하시겠습니까?",
|
||||
"viewAuditLog": "감사 로그 보기",
|
||||
"yourAccessLevel": "귀하의 액세스 수준"
|
||||
},
|
||||
"actAs": {
|
||||
"title": "로 활동",
|
||||
"noAccountSpecified": "계정 DID가 지정되지 않았습니다",
|
||||
"failedToVerify": "계정 액세스를 확인하지 못했습니다",
|
||||
"noAccess": "이 계정에 대한 액세스 권한이 없습니다",
|
||||
"failedToInitiate": "인증 시작에 실패했습니다",
|
||||
"invalidResponse": "서버에서 잘못된 응답을 받았습니다",
|
||||
"failedError": "실패: {error}",
|
||||
"preparing": "위임 계정 로그인 준비 중...",
|
||||
"backToControllers": "컨트롤러로 돌아가기"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "로딩 중...",
|
||||
"title": "위임 계정",
|
||||
"isDelegated": "{handle}은(는) 위임 계정입니다.",
|
||||
"enterControllerHandle": "이 계정에 액세스하려면 컨트롤러 계정으로 로그인하세요.",
|
||||
"controllerHandle": "컨트롤러 핸들",
|
||||
"handlePlaceholder": "handle.example.com",
|
||||
"checking": "확인 중...",
|
||||
"controllerNotFound": "계정을 찾을 수 없거나 이 위임 계정에 대한 액세스 권한이 없습니다",
|
||||
"missingParams": "위임 매개변수가 없습니다",
|
||||
"missingInfo": "필요한 정보가 없습니다",
|
||||
"passkeyCancelled": "패스키 인증이 취소되었습니다",
|
||||
"passkeyFailed": "패스키 인증에 실패했습니다",
|
||||
"failedPasskeyStart": "패스키 로그인 시작에 실패했습니다",
|
||||
"authFailed": "인증에 실패했습니다",
|
||||
"unexpectedResponse": "서버에서 예기치 않은 응답을 받았습니다",
|
||||
"signInAsController": "컨트롤러로 로그인",
|
||||
"authenticateAs": "{controller}(으)로 인증하여 {delegated}를 대신합니다",
|
||||
"useDifferentController": "다른 컨트롤러 사용",
|
||||
"signInWithPasskey": "패스키로 로그인",
|
||||
"authenticating": "인증 중...",
|
||||
"usePasskey": "패스키 사용",
|
||||
"or": "또는",
|
||||
"password": "비밀번호",
|
||||
"enterPassword": "비밀번호 입력",
|
||||
"rememberDevice": "이 기기 기억하기",
|
||||
"signingIn": "로그인 중...",
|
||||
"signIn": "로그인",
|
||||
"goBack": "뒤로",
|
||||
"unableToLoad": "위임 정보를 로드할 수 없습니다"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "위임 액세스",
|
||||
"actingAs": "활동 계정",
|
||||
"controller": "컨트롤러",
|
||||
"accessLevel": "액세스 수준",
|
||||
"readOnlyAccess": "읽기 전용 액세스",
|
||||
"readOnlyDesc": "공개 정보만 볼 수 있습니다. 이 계정에 대한 쓰기 권한이 없습니다.",
|
||||
"permissionsLimited": "권한 제한됨",
|
||||
"permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.",
|
||||
"viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.",
|
||||
"editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "Avbryt",
|
||||
"back": "Tillbaka",
|
||||
"done": "Klar",
|
||||
"continue": "Fortsätt",
|
||||
"refresh": "Uppdatera",
|
||||
"create": "Skapa",
|
||||
"delete": "Radera",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "Full åtkomst",
|
||||
"scopeReadOnly": "Endast läsning",
|
||||
"scopePostOnly": "Endast publicering",
|
||||
"scopeCustom": "Anpassad"
|
||||
"scopeCustom": "Anpassad",
|
||||
"byController": "Av controller"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Aktiva sessioner",
|
||||
@@ -888,5 +890,142 @@
|
||||
"codeLabel": "Verifieringskod",
|
||||
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.",
|
||||
"verifyButton": "Verifiera"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "Kontodelegering",
|
||||
"controllers": "Kontrollanter",
|
||||
"controllersDescription": "Kontrollanter kan agera som administratörer för ditt konto. De kan utföra åtgärder du tillåter, skapa inlägg för din räkning och modifiera din dataförvaring.",
|
||||
"controlledAccounts": "Kontrollerade konton",
|
||||
"controlledAccountsDescription": "Detta är konton där du har lagts till som kontrollant. Du kan utföra tillåtna åtgärder på dessa konton.",
|
||||
"noControllers": "Inga kontrollanter ännu",
|
||||
"noControlledAccounts": "Inga kontrollerade konton",
|
||||
"addController": "Lägg till kontrollant",
|
||||
"revokeAccess": "Återkalla åtkomst",
|
||||
"revokeConfirm": "Återkalla denna kontrollants åtkomst? De kommer inte längre kunna utföra åtgärder på ditt konto.",
|
||||
"handle": "Användarnamn",
|
||||
"handlePlaceholder": "@user.bsky.social",
|
||||
"did": "DID",
|
||||
"didPlaceholder": "did:plc:...",
|
||||
"scopes": "Behörighetsnivå",
|
||||
"scopeOwner": "Ägare",
|
||||
"scopeOwnerDesc": "Fullständig kontroll (kan utföra alla åtgärder)",
|
||||
"scopeAdmin": "Administratör",
|
||||
"scopeAdminDesc": "Hantera inlägg, applösenord, inställningar",
|
||||
"scopeEditor": "Redaktör",
|
||||
"scopeEditorDesc": "Skapa och hantera inlägg, gillningar, följningar",
|
||||
"scopeViewer": "Läsare",
|
||||
"scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar",
|
||||
"scopeCustom": "Anpassad",
|
||||
"scopeCustomDesc": "Välj individuella behörigheter",
|
||||
"grantedAt": "Beviljad",
|
||||
"expiresAt": "Upphör",
|
||||
"noExpiration": "Ingen utgång",
|
||||
"actAs": "Agera som",
|
||||
"auditLog": "Granskningslogg",
|
||||
"auditLogTitle": "Delegerings-granskningslogg",
|
||||
"backToControllers": "← Tillbaka till kontrollanter",
|
||||
"loading": "Laddar...",
|
||||
"noActivity": "Ingen aktivitet ännu",
|
||||
"actor": "Aktör",
|
||||
"controller": "Kontrollant",
|
||||
"account": "Konto",
|
||||
"details": "Detaljer",
|
||||
"actionGrantCreated": "Behörighet skapad",
|
||||
"actionGrantRevoked": "Behörighet återkallad",
|
||||
"actionScopesModified": "Behörigheter ändrade",
|
||||
"actionTokenIssued": "Token utfärdad",
|
||||
"actionRepoWrite": "Dataförvarsskrivning",
|
||||
"actionBlobUpload": "Blob-uppladdning",
|
||||
"actionAccountAction": "Kontoåtgärd",
|
||||
"previous": "Föregående",
|
||||
"next": "Nästa",
|
||||
"showing": "{start}–{end} av {total}",
|
||||
"refresh": "Uppdatera",
|
||||
"failedToLoadAuditLog": "Kunde inte ladda granskningsloggen",
|
||||
"addControllerTitle": "Lägg till kontrollant",
|
||||
"addControllerDescription": "Lägg till en användare som kan utföra åtgärder på detta konto med specificerade behörigheter.",
|
||||
"controllerIdentifier": "Kontrollantens användarnamn eller DID",
|
||||
"selectScopes": "Välj behörighetsnivå",
|
||||
"add": "Lägg till",
|
||||
"adding": "Lägger till...",
|
||||
"cancel": "Avbryt",
|
||||
"accessLevel": "Åtkomstnivå",
|
||||
"addControllerButton": "+ Lägg till kontrollant",
|
||||
"auditLogDesc": "Visa all delegeringsaktivitet",
|
||||
"cannotAddControllers": "Du kan inte lägga till kontrollanter eftersom detta konto kontrollerar andra konton. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.",
|
||||
"cannotControlAccounts": "Du kan inte kontrollera andra konton eftersom detta konto har kontrollanter. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.",
|
||||
"controlledAccountsDesc": "Konton du kan agera för",
|
||||
"controllerAdded": "Kontrollant tillagd",
|
||||
"controllerDid": "Kontrollant-DID",
|
||||
"controllerRemoved": "Kontrollant borttagen",
|
||||
"controllersDesc": "Konton som kan agera för dig",
|
||||
"createAccount": "Skapa konto",
|
||||
"createDelegatedAccount": "Skapa delegerat konto",
|
||||
"createDelegatedAccountButton": "+ Skapa delegerat konto",
|
||||
"creating": "Skapar...",
|
||||
"emailOptional": "E-post (valfritt)",
|
||||
"failedToAddController": "Kunde inte lägga till kontrollant",
|
||||
"failedToCreateAccount": "Kunde inte skapa delegerat konto",
|
||||
"failedToRemoveController": "Kunde inte ta bort kontrollant",
|
||||
"granted": "Beviljad",
|
||||
"inactive": "Inaktiv",
|
||||
"remove": "Ta bort",
|
||||
"removeConfirm": "Vill du ta bort denna kontrollant?",
|
||||
"viewAuditLog": "Visa granskningslogg",
|
||||
"yourAccessLevel": "Din åtkomstnivå"
|
||||
},
|
||||
"actAs": {
|
||||
"title": "Agera som",
|
||||
"noAccountSpecified": "Inget konto-DID angivet",
|
||||
"failedToVerify": "Kunde inte verifiera kontoåtkomst",
|
||||
"noAccess": "Du har inte åtkomst till detta konto",
|
||||
"failedToInitiate": "Kunde inte initiera autentisering",
|
||||
"invalidResponse": "Ogiltigt svar från servern",
|
||||
"failedError": "Misslyckades: {error}",
|
||||
"preparing": "Förbereder inloggning till delegerat konto...",
|
||||
"backToControllers": "Tillbaka till kontrollanter"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "Laddar...",
|
||||
"title": "Delegerat konto",
|
||||
"isDelegated": "{handle} är ett delegerat konto.",
|
||||
"enterControllerHandle": "Logga in med ditt kontrollantkonto för att komma åt detta konto.",
|
||||
"controllerHandle": "Kontrollantens användarnamn",
|
||||
"handlePlaceholder": "handle.example.com",
|
||||
"checking": "Kontrollerar...",
|
||||
"controllerNotFound": "Kontot hittades inte eller så har du inte åtkomst till detta delegerade konto",
|
||||
"missingParams": "Delegeringsparametrar saknas",
|
||||
"missingInfo": "Nödvändig information saknas",
|
||||
"passkeyCancelled": "Nyckelautentisering avbröts",
|
||||
"passkeyFailed": "Nyckelautentisering misslyckades",
|
||||
"failedPasskeyStart": "Kunde inte starta nyckelinloggning",
|
||||
"authFailed": "Autentisering misslyckades",
|
||||
"unexpectedResponse": "Oväntat svar från servern",
|
||||
"signInAsController": "Logga in som kontrollant",
|
||||
"authenticateAs": "Autentisera som {controller} för att agera på uppdrag av {delegated}",
|
||||
"useDifferentController": "Använd en annan kontrollant",
|
||||
"signInWithPasskey": "Logga in med nyckel",
|
||||
"authenticating": "Autentiserar...",
|
||||
"usePasskey": "Använd nyckel",
|
||||
"or": "eller",
|
||||
"password": "Lösenord",
|
||||
"enterPassword": "Ange lösenord",
|
||||
"rememberDevice": "Kom ihåg denna enhet",
|
||||
"signingIn": "Loggar in...",
|
||||
"signIn": "Logga in",
|
||||
"goBack": "Gå tillbaka",
|
||||
"unableToLoad": "Kunde inte ladda delegeringsinformation"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "Delegerad åtkomst",
|
||||
"actingAs": "Agerar som",
|
||||
"controller": "Kontrollant",
|
||||
"accessLevel": "Åtkomstnivå",
|
||||
"readOnlyAccess": "Endast läsåtkomst",
|
||||
"readOnlyDesc": "Visa endast offentlig information. Ingen skrivåtkomst till detta konto.",
|
||||
"permissionsLimited": "Behörigheter begränsade",
|
||||
"permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.",
|
||||
"viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.",
|
||||
"editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"done": "完成",
|
||||
"continue": "继续",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"delete": "删除",
|
||||
@@ -271,7 +272,8 @@
|
||||
"scopeFull": "完全访问",
|
||||
"scopeReadOnly": "只读",
|
||||
"scopePostOnly": "仅发帖",
|
||||
"scopeCustom": "自定义"
|
||||
"scopeCustom": "自定义",
|
||||
"byController": "由控制者创建"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "登录会话",
|
||||
@@ -871,5 +873,142 @@
|
||||
"codeLabel": "验证码",
|
||||
"codeHelp": "复制消息中的完整验证码,包括横线。",
|
||||
"verifyButton": "验证"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "账户委托",
|
||||
"controllers": "控制者",
|
||||
"controllersDescription": "控制者可以作为您账户的管理员。他们可以执行您允许的操作,代表您发布帖子,以及修改您的数据仓库。",
|
||||
"controlledAccounts": "受控账户",
|
||||
"controlledAccountsDescription": "这些是您被添加为控制者的账户。您可以在这些账户上执行允许的操作。",
|
||||
"noControllers": "暂无控制者",
|
||||
"noControlledAccounts": "无受控账户",
|
||||
"addController": "添加控制者",
|
||||
"revokeAccess": "撤销访问",
|
||||
"revokeConfirm": "撤销此控制者的访问权限?他们将无法再在您的账户上执行操作。",
|
||||
"handle": "用户名",
|
||||
"handlePlaceholder": "@user.bsky.social",
|
||||
"did": "DID",
|
||||
"didPlaceholder": "did:plc:...",
|
||||
"scopes": "权限级别",
|
||||
"scopeOwner": "所有者",
|
||||
"scopeOwnerDesc": "完全控制(可执行所有操作)",
|
||||
"scopeAdmin": "管理员",
|
||||
"scopeAdminDesc": "管理帖子、应用专用密码、设置",
|
||||
"scopeEditor": "编辑者",
|
||||
"scopeEditorDesc": "创建和管理帖子、点赞、关注",
|
||||
"scopeViewer": "查看者",
|
||||
"scopeViewerDesc": "只读访问数据仓库和设置",
|
||||
"scopeCustom": "自定义",
|
||||
"scopeCustomDesc": "选择单独的权限",
|
||||
"grantedAt": "授权时间",
|
||||
"expiresAt": "过期时间",
|
||||
"noExpiration": "永不过期",
|
||||
"actAs": "代理操作",
|
||||
"auditLog": "审计日志",
|
||||
"auditLogTitle": "委托审计日志",
|
||||
"backToControllers": "← 返回控制者",
|
||||
"loading": "加载中...",
|
||||
"noActivity": "暂无活动",
|
||||
"actor": "执行者",
|
||||
"controller": "控制者",
|
||||
"account": "账户",
|
||||
"details": "详情",
|
||||
"actionGrantCreated": "授权创建",
|
||||
"actionGrantRevoked": "授权撤销",
|
||||
"actionScopesModified": "权限修改",
|
||||
"actionTokenIssued": "令牌发放",
|
||||
"actionRepoWrite": "仓库写入",
|
||||
"actionBlobUpload": "Blob上传",
|
||||
"actionAccountAction": "账户操作",
|
||||
"previous": "上一页",
|
||||
"next": "下一页",
|
||||
"showing": "{start}–{end} / 共{total}条",
|
||||
"refresh": "刷新",
|
||||
"failedToLoadAuditLog": "加载审计日志失败",
|
||||
"addControllerTitle": "添加控制者",
|
||||
"addControllerDescription": "添加一个可以在此账户上执行指定权限操作的用户。",
|
||||
"controllerIdentifier": "控制者用户名或 DID",
|
||||
"selectScopes": "选择权限级别",
|
||||
"add": "添加",
|
||||
"adding": "添加中...",
|
||||
"cancel": "取消",
|
||||
"accessLevel": "访问级别",
|
||||
"addControllerButton": "+ 添加控制者",
|
||||
"auditLogDesc": "查看所有委托活动",
|
||||
"cannotAddControllers": "因为此账户正在控制其他账户,所以无法添加控制者。账户只能拥有控制者或控制其他账户,不能同时两者兼备。",
|
||||
"cannotControlAccounts": "因为此账户有控制者,所以无法控制其他账户。账户只能拥有控制者或控制其他账户,不能同时两者兼备。",
|
||||
"controlledAccountsDesc": "您可以代理操作的账户",
|
||||
"controllerAdded": "控制者已添加",
|
||||
"controllerDid": "控制者 DID",
|
||||
"controllerRemoved": "控制者已移除",
|
||||
"controllersDesc": "可以代理操作您账户的账户",
|
||||
"createAccount": "创建账户",
|
||||
"createDelegatedAccount": "创建委托账户",
|
||||
"createDelegatedAccountButton": "+ 创建委托账户",
|
||||
"creating": "创建中...",
|
||||
"emailOptional": "邮箱(可选)",
|
||||
"failedToAddController": "添加控制者失败",
|
||||
"failedToCreateAccount": "创建委托账户失败",
|
||||
"failedToRemoveController": "移除控制者失败",
|
||||
"granted": "授权日期",
|
||||
"inactive": "未激活",
|
||||
"remove": "移除",
|
||||
"removeConfirm": "确定要移除此控制者吗?",
|
||||
"viewAuditLog": "查看审计日志",
|
||||
"yourAccessLevel": "您的访问级别"
|
||||
},
|
||||
"actAs": {
|
||||
"title": "代理操作",
|
||||
"noAccountSpecified": "未指定账户 DID",
|
||||
"failedToVerify": "无法验证账户访问权限",
|
||||
"noAccess": "您没有此账户的访问权限",
|
||||
"failedToInitiate": "无法启动认证",
|
||||
"invalidResponse": "服务器返回无效响应",
|
||||
"failedError": "失败: {error}",
|
||||
"preparing": "正在准备登录委托账户...",
|
||||
"backToControllers": "返回控制者"
|
||||
},
|
||||
"oauthDelegation": {
|
||||
"loading": "加载中...",
|
||||
"title": "委托账户",
|
||||
"isDelegated": "{handle} 是一个委托账户。",
|
||||
"enterControllerHandle": "请使用您的控制者账户登录以访问此账户。",
|
||||
"controllerHandle": "控制者用户名",
|
||||
"handlePlaceholder": "handle.example.com",
|
||||
"checking": "检查中...",
|
||||
"controllerNotFound": "账户未找到或您没有权限访问此委托账户",
|
||||
"missingParams": "缺少委托参数",
|
||||
"missingInfo": "缺少必要信息",
|
||||
"passkeyCancelled": "通行密钥认证已取消",
|
||||
"passkeyFailed": "通行密钥认证失败",
|
||||
"failedPasskeyStart": "无法启动通行密钥登录",
|
||||
"authFailed": "认证失败",
|
||||
"unexpectedResponse": "服务器返回意外响应",
|
||||
"signInAsController": "以控制者身份登录",
|
||||
"authenticateAs": "以 {controller} 身份认证以代表 {delegated} 操作",
|
||||
"useDifferentController": "使用其他控制者",
|
||||
"signInWithPasskey": "使用通行密钥登录",
|
||||
"authenticating": "认证中...",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"or": "或",
|
||||
"password": "密码",
|
||||
"enterPassword": "输入密码",
|
||||
"rememberDevice": "记住此设备",
|
||||
"signingIn": "登录中...",
|
||||
"signIn": "登录",
|
||||
"goBack": "返回",
|
||||
"unableToLoad": "无法加载委托信息"
|
||||
},
|
||||
"oauthConsent": {
|
||||
"delegatedAccess": "委托访问",
|
||||
"actingAs": "代理操作",
|
||||
"controller": "控制者",
|
||||
"accessLevel": "访问级别",
|
||||
"readOnlyAccess": "只读访问",
|
||||
"readOnlyDesc": "仅查看公开信息。无法对此账户进行写入操作。",
|
||||
"permissionsLimited": "权限受限",
|
||||
"permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。",
|
||||
"viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。",
|
||||
"editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth'
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
const auth = getAuthState()
|
||||
let error = $state<string | null>(null)
|
||||
let loading = $state(true)
|
||||
let actAsInProgress = $state(false)
|
||||
|
||||
function getDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return params.get('did')
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session && !actAsInProgress) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session && !actAsInProgress) {
|
||||
actAsInProgress = true
|
||||
initiateActAs()
|
||||
}
|
||||
})
|
||||
|
||||
async function initiateActAs() {
|
||||
const did = getDid()
|
||||
if (!did) {
|
||||
error = $_('actAs.noAccountSpecified')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/xrpc/com.tranquil.delegation.listControlledAccounts`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` }
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
error = $_('actAs.failedToVerify')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const account = data.accounts?.find((a: { did: string }) => a.did === did)
|
||||
|
||||
if (!account) {
|
||||
error = $_('actAs.noAccess')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
await logout()
|
||||
|
||||
const hostname = window.location.origin
|
||||
const state = generateState()
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
saveOAuthState({ state, codeVerifier })
|
||||
|
||||
const parResponse = await fetch('/oauth/par', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: `${hostname}/oauth/client-metadata.json`,
|
||||
redirect_uri: `${hostname}/`,
|
||||
response_type: 'code',
|
||||
scope: 'atproto',
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
login_hint: account.handle
|
||||
})
|
||||
})
|
||||
|
||||
if (!parResponse.ok) {
|
||||
error = $_('actAs.failedToInitiate')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
const parData = await parResponse.json()
|
||||
if (parData.request_uri) {
|
||||
window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}`
|
||||
} else {
|
||||
error = $_('actAs.invalidResponse')
|
||||
loading = false
|
||||
}
|
||||
} catch (e) {
|
||||
error = $_('actAs.failedError', { values: { error: e instanceof Error ? e.message : String(e) } })
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
navigate('/controllers')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<p>{$_('actAs.preparing')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<header>
|
||||
<h1>{$_('actAs.title')}</h1>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="back-btn" onclick={goBack}>
|
||||
{$_('actAs.backToControllers')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
padding: var(--space-3);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -173,6 +173,9 @@
|
||||
<span class="name">{pw.name}</span>
|
||||
<span class="meta">
|
||||
<span class="scope-badge" class:full={!pw.scopes}>{getScopeLabel(pw.scopes)}</span>
|
||||
{#if pw.createdByController}
|
||||
<span class="controller-badge" title={pw.createdByController}>{$_('appPasswords.byController')}</span>
|
||||
{/if}
|
||||
<span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -418,6 +421,16 @@
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.controller-badge {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--info-bg, #e3f2fd);
|
||||
border: 1px solid var(--info-border, #90caf9);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--info-text, #1565c0);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { formatDateTime } from '../lib/date'
|
||||
|
||||
interface Controller {
|
||||
did: string
|
||||
handle: string
|
||||
grantedScopes: string
|
||||
grantedAt: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface ControlledAccount {
|
||||
did: string
|
||||
handle: string
|
||||
grantedScopes: string
|
||||
grantedAt: string
|
||||
}
|
||||
|
||||
interface ScopePreset {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
scopes: string
|
||||
}
|
||||
|
||||
const auth = getAuthState()
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let success = $state<string | null>(null)
|
||||
let controllers = $state<Controller[]>([])
|
||||
let controlledAccounts = $state<ControlledAccount[]>([])
|
||||
let scopePresets = $state<ScopePreset[]>([])
|
||||
|
||||
let hasControllers = $derived(controllers.length > 0)
|
||||
let controlsAccounts = $derived(controlledAccounts.length > 0)
|
||||
let canAddControllers = $derived(!controlsAccounts)
|
||||
let canControlAccounts = $derived(!hasControllers)
|
||||
|
||||
let showAddController = $state(false)
|
||||
let addControllerDid = $state('')
|
||||
let addControllerScopes = $state('atproto')
|
||||
let addingController = $state(false)
|
||||
|
||||
let showCreateDelegated = $state(false)
|
||||
let newDelegatedHandle = $state('')
|
||||
let newDelegatedEmail = $state('')
|
||||
let newDelegatedScopes = $state('atproto')
|
||||
let creatingDelegated = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControllers() {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', {
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
controllers = data.controllers || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load controllers:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControlledAccounts() {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', {
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
controlledAccounts = data.accounts || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load controlled accounts:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScopePresets() {
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
scopePresets = data.presets || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load scope presets:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addController() {
|
||||
if (!auth.session || !addControllerDid.trim()) return
|
||||
addingController = true
|
||||
error = null
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.addController', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
controller_did: addControllerDid.trim(),
|
||||
granted_scopes: addControllerScopes
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
error = data.message || data.error || $_('delegation.failedToAddController')
|
||||
return
|
||||
}
|
||||
|
||||
success = $_('delegation.controllerAdded')
|
||||
addControllerDid = ''
|
||||
addControllerScopes = 'atproto'
|
||||
showAddController = false
|
||||
await loadControllers()
|
||||
} catch (e) {
|
||||
error = $_('delegation.failedToAddController')
|
||||
} finally {
|
||||
addingController = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeController(controllerDid: string) {
|
||||
if (!auth.session) return
|
||||
if (!confirm($_('delegation.removeConfirm'))) return
|
||||
|
||||
error = null
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.removeController', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ controller_did: controllerDid })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
error = data.message || data.error || $_('delegation.failedToRemoveController')
|
||||
return
|
||||
}
|
||||
|
||||
success = $_('delegation.controllerRemoved')
|
||||
await loadControllers()
|
||||
} catch (e) {
|
||||
error = $_('delegation.failedToRemoveController')
|
||||
}
|
||||
}
|
||||
|
||||
async function createDelegatedAccount() {
|
||||
if (!auth.session || !newDelegatedHandle.trim()) return
|
||||
creatingDelegated = true
|
||||
error = null
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
handle: newDelegatedHandle.trim(),
|
||||
email: newDelegatedEmail.trim() || undefined,
|
||||
controllerScopes: newDelegatedScopes
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
error = data.message || data.error || $_('delegation.failedToCreateAccount')
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
success = $_('delegation.accountCreated', { values: { handle: data.handle } })
|
||||
newDelegatedHandle = ''
|
||||
newDelegatedEmail = ''
|
||||
newDelegatedScopes = 'atproto'
|
||||
showCreateDelegated = false
|
||||
await loadControlledAccounts()
|
||||
} catch (e) {
|
||||
error = $_('delegation.failedToCreateAccount')
|
||||
} finally {
|
||||
creatingDelegated = false
|
||||
}
|
||||
}
|
||||
|
||||
function getScopeLabel(scopes: string): string {
|
||||
const preset = scopePresets.find(p => p.scopes === scopes)
|
||||
if (preset) return preset.label
|
||||
if (scopes === 'atproto') return $_('delegation.scopeOwner')
|
||||
if (scopes === '') return $_('delegation.scopeViewer')
|
||||
return $_('delegation.scopeCustom')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('delegation.title')}</h1>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{$_('delegation.loading')}</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{$_('delegation.controllers')}</h2>
|
||||
<p class="section-description">{$_('delegation.controllersDesc')}</p>
|
||||
</div>
|
||||
|
||||
{#if controllers.length === 0}
|
||||
<p class="empty">{$_('delegation.noControllers')}</p>
|
||||
{:else}
|
||||
<div class="items-list">
|
||||
{#each controllers as controller}
|
||||
<div class="item-card" class:inactive={!controller.isActive}>
|
||||
<div class="item-info">
|
||||
<div class="item-header">
|
||||
<span class="item-handle">@{controller.handle}</span>
|
||||
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
|
||||
{#if !controller.isActive}
|
||||
<span class="badge inactive">{$_('delegation.inactive')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.did')}</span>
|
||||
<span class="value did">{controller.did}</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.granted')}</span>
|
||||
<span class="value">{formatDateTime(controller.grantedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="danger-outline" onclick={() => removeController(controller.did)}>
|
||||
{$_('delegation.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !canAddControllers}
|
||||
<div class="constraint-notice">
|
||||
<p>{$_('delegation.cannotAddControllers')}</p>
|
||||
</div>
|
||||
{:else if showAddController}
|
||||
<div class="form-card">
|
||||
<h3>{$_('delegation.addController')}</h3>
|
||||
<div class="field">
|
||||
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
|
||||
<input
|
||||
id="controllerDid"
|
||||
type="text"
|
||||
bind:value={addControllerDid}
|
||||
placeholder="did:plc:..."
|
||||
disabled={addingController}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
|
||||
<select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
|
||||
{#each scopePresets as preset}
|
||||
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="ghost" onclick={() => showAddController = false} disabled={addingController}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button onclick={addController} disabled={addingController || !addControllerDid.trim()}>
|
||||
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="ghost full-width" onclick={() => showAddController = true}>
|
||||
{$_('delegation.addControllerButton')}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{$_('delegation.controlledAccounts')}</h2>
|
||||
<p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
|
||||
</div>
|
||||
|
||||
{#if controlledAccounts.length === 0}
|
||||
<p class="empty">{$_('delegation.noControlledAccounts')}</p>
|
||||
{:else}
|
||||
<div class="items-list">
|
||||
{#each controlledAccounts as account}
|
||||
<div class="item-card">
|
||||
<div class="item-info">
|
||||
<div class="item-header">
|
||||
<span class="item-handle">@{account.handle}</span>
|
||||
<span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.did')}</span>
|
||||
<span class="value did">{account.did}</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.granted')}</span>
|
||||
<span class="value">{formatDateTime(account.grantedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
|
||||
{$_('delegation.actAs')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !canControlAccounts}
|
||||
<div class="constraint-notice">
|
||||
<p>{$_('delegation.cannotControlAccounts')}</p>
|
||||
</div>
|
||||
{:else if showCreateDelegated}
|
||||
<div class="form-card">
|
||||
<h3>{$_('delegation.createDelegatedAccount')}</h3>
|
||||
<div class="field">
|
||||
<label for="delegatedHandle">{$_('delegation.handle')}</label>
|
||||
<input
|
||||
id="delegatedHandle"
|
||||
type="text"
|
||||
bind:value={newDelegatedHandle}
|
||||
placeholder="username"
|
||||
disabled={creatingDelegated}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
|
||||
<input
|
||||
id="delegatedEmail"
|
||||
type="email"
|
||||
bind:value={newDelegatedEmail}
|
||||
placeholder="email@example.com"
|
||||
disabled={creatingDelegated}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
|
||||
<select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
|
||||
{#each scopePresets as preset}
|
||||
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
|
||||
{creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="ghost full-width" onclick={() => showCreateDelegated = true}>
|
||||
{$_('delegation.createDelegatedAccountButton')}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{$_('delegation.auditLog')}</h2>
|
||||
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
|
||||
</div>
|
||||
<a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.constraint-notice {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.constraint-notice p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0 0 var(--space-1) 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-card.inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
margin-bottom: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-handle {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.badge.scope {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.badge.inactive {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.detail .label {
|
||||
color: var(--text-secondary);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.detail .value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail .value.did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.item-actions button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
display: inline-block;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
text-decoration: none;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.form-card h3 {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -186,6 +186,10 @@
|
||||
<h3>{$_('dashboard.navRepo')}</h3>
|
||||
<p>{$_('dashboard.navRepoDesc')}</p>
|
||||
</a>
|
||||
<a href="#/controllers" class="nav-card">
|
||||
<h3>Delegation</h3>
|
||||
<p>Manage account controllers and delegated accounts</p>
|
||||
</a>
|
||||
{#if auth.session.isAdmin}
|
||||
<a href="#/admin" class="nav-card admin-card">
|
||||
<h3>{$_('dashboard.navAdmin')}</h3>
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { formatDateTime } from '../lib/date'
|
||||
|
||||
interface AuditEntry {
|
||||
id: string
|
||||
delegatedDid: string
|
||||
actorDid: string
|
||||
controllerDid: string | null
|
||||
actionType: string
|
||||
actionDetails: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const auth = getAuthState()
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let entries = $state<AuditEntry[]>([])
|
||||
let total = $state(0)
|
||||
let offset = $state(0)
|
||||
const limit = 20
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadAuditLog()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadAuditLog() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
error = data.message || data.error || $_('delegation.failedToLoadAuditLog')
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
entries = data.entries || []
|
||||
total = data.total || 0
|
||||
} catch (e) {
|
||||
error = $_('delegation.failedToLoadAuditLog')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (offset > 0) {
|
||||
offset = Math.max(0, offset - limit)
|
||||
loadAuditLog()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (offset + limit < total) {
|
||||
offset = offset + limit
|
||||
loadAuditLog()
|
||||
}
|
||||
}
|
||||
|
||||
function formatActionType(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'GrantCreated': $_('delegation.actionGrantCreated'),
|
||||
'GrantRevoked': $_('delegation.actionGrantRevoked'),
|
||||
'ScopesModified': $_('delegation.actionScopesModified'),
|
||||
'TokenIssued': $_('delegation.actionTokenIssued'),
|
||||
'RepoWrite': $_('delegation.actionRepoWrite'),
|
||||
'BlobUpload': $_('delegation.actionBlobUpload'),
|
||||
'AccountAction': $_('delegation.actionAccountAction')
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
function formatActionDetails(details: Record<string, unknown> | null): string {
|
||||
if (!details) return ''
|
||||
const parts: string[] = []
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
const formattedKey = key.replace(/_/g, ' ')
|
||||
parts.push(`${formattedKey}: ${JSON.stringify(value)}`)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (did.length <= 30) return did
|
||||
return did.substring(0, 20) + '...' + did.substring(did.length - 6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a>
|
||||
<h1>{$_('delegation.auditLogTitle')}</h1>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{$_('delegation.loading')}</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if entries.length === 0}
|
||||
<p class="empty">{$_('delegation.noActivity')}</p>
|
||||
{:else}
|
||||
<div class="audit-list">
|
||||
{#each entries as entry}
|
||||
<div class="audit-entry">
|
||||
<div class="entry-header">
|
||||
<span class="action-type">{formatActionType(entry.actionType)}</span>
|
||||
<span class="timestamp">{formatDateTime(entry.createdAt)}</span>
|
||||
</div>
|
||||
<div class="entry-details">
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.actor')}</span>
|
||||
<span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span>
|
||||
</div>
|
||||
{#if entry.controllerDid}
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.controller')}</span>
|
||||
<span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.account')}</span>
|
||||
<span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span>
|
||||
</div>
|
||||
{#if entry.actionDetails}
|
||||
<div class="detail">
|
||||
<span class="label">{$_('delegation.details')}</span>
|
||||
<span class="value details">{formatActionDetails(entry.actionDetails)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button
|
||||
class="ghost"
|
||||
onclick={prevPage}
|
||||
disabled={offset === 0}
|
||||
>
|
||||
{$_('delegation.previous')}
|
||||
</button>
|
||||
<span class="page-info">
|
||||
{$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })}
|
||||
</span>
|
||||
<button
|
||||
class="ghost"
|
||||
onclick={nextPage}
|
||||
disabled={offset + limit >= total}
|
||||
>
|
||||
{$_('delegation.next')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions-bar">
|
||||
<button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: var(--space-2) 0 0 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
padding: var(--space-3);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.audit-entry {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.action-type {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.entry-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail .label {
|
||||
color: var(--text-secondary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.detail .value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail .value.did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail .value.details {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions-bar button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -178,6 +178,11 @@
|
||||
<h3>App passwords with guardrails</h3>
|
||||
<p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Delegate without sharing passwords</h3>
|
||||
<p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Everything in one place</h2>
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
scopes: ScopeInfo[]
|
||||
show_consent: boolean
|
||||
did: string
|
||||
is_delegation?: boolean
|
||||
controller_did?: string
|
||||
controller_handle?: string
|
||||
delegation_level?: string
|
||||
}
|
||||
|
||||
let loading = $state(true)
|
||||
@@ -77,10 +81,14 @@
|
||||
if (!consentData) return
|
||||
|
||||
submitting = true
|
||||
const approvedScopes = Object.entries(scopeSelections)
|
||||
let approvedScopes = Object.entries(scopeSelections)
|
||||
.filter(([_, approved]) => approved)
|
||||
.map(([scope]) => scope)
|
||||
|
||||
if (approvedScopes.length === 0 && consentData.scopes.length === 0) {
|
||||
approvedScopes = ['atproto']
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/consent', {
|
||||
method: 'POST',
|
||||
@@ -183,36 +191,82 @@
|
||||
</div>
|
||||
|
||||
<div class="account-info">
|
||||
<span class="label">{$_('oauth.consent.signingInAs')}</span>
|
||||
<span class="did">{consentData.did}</span>
|
||||
{#if consentData.is_delegation}
|
||||
<div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div>
|
||||
<div class="delegation-info">
|
||||
<div class="info-row">
|
||||
<span class="label">{$_('oauthConsent.actingAs')}</span>
|
||||
<span class="did">{consentData.did}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{$_('oauthConsent.controller')}</span>
|
||||
<span class="handle">@{consentData.controller_handle || consentData.controller_did}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">{$_('oauthConsent.accessLevel')}</span>
|
||||
<span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if consentData.delegation_level && consentData.delegation_level !== 'Owner'}
|
||||
<div class="permissions-notice">
|
||||
<div class="notice-header">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span>{$_('oauthConsent.permissionsLimited')}</span>
|
||||
</div>
|
||||
<p class="notice-text">
|
||||
{#if consentData.delegation_level === 'Viewer'}
|
||||
{$_('oauthConsent.viewerLimitedDesc')}
|
||||
{:else if consentData.delegation_level === 'Editor'}
|
||||
{$_('oauthConsent.editorLimitedDesc')}
|
||||
{:else}
|
||||
{$_('oauthConsent.permissionsLimitedDesc', { values: { level: consentData.delegation_level } })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="label">{$_('oauth.consent.signingInAs')}</span>
|
||||
<span class="did">{consentData.did}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permissions-panel">
|
||||
<div class="scopes-section">
|
||||
<h2>{$_('oauth.consent.permissionsRequested')}</h2>
|
||||
{#each Object.entries(scopeGroups) as [category, scopes]}
|
||||
<div class="scope-group">
|
||||
<h3 class="category-title">{category}</h3>
|
||||
{#each scopes as scope}
|
||||
<label class="scope-item" class:required={scope.required}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting}
|
||||
onchange={() => handleScopeToggle(scope.scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{scope.display_name}</span>
|
||||
<span class="scope-description">{scope.description}</span>
|
||||
{#if scope.required}
|
||||
<span class="required-badge">{$_('oauth.consent.required')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
{#if consentData.scopes.length === 0}
|
||||
<div class="read-only-notice">
|
||||
<div class="scope-item read-only">
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{$_('oauthConsent.readOnlyAccess')}</span>
|
||||
<span class="scope-description">{$_('oauthConsent.readOnlyDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each Object.entries(scopeGroups) as [category, scopes]}
|
||||
<div class="scope-group">
|
||||
<h3 class="category-title">{category}</h3>
|
||||
{#each scopes as scope}
|
||||
<label class="scope-item" class:required={scope.required}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting}
|
||||
onchange={() => handleScopeToggle(scope.scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{scope.display_name}</span>
|
||||
<span class="scope-description">{scope.description}</span>
|
||||
{#if scope.required}
|
||||
<span class="required-badge">{$_('oauth.consent.required')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="remember-choice">
|
||||
@@ -339,6 +393,94 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.delegation-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.delegation-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.delegation-info .info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.delegation-info .handle {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.level-badge.level-owner {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.level-badge.level-admin {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.level-badge.level-editor {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.level-badge.level-viewer {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.permissions-notice {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.notice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--warning-text);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.notice-header svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--warning-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scopes-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
@@ -382,6 +524,11 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.scope-item.read-only {
|
||||
background: var(--bg-secondary);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.scope-item input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
let delegatedDid = $state<string | null>(null)
|
||||
let delegatedHandle = $state<string | null>(null)
|
||||
let controllerIdentifier = $state('')
|
||||
let controllerDid = $state<string | null>(null)
|
||||
let password = $state('')
|
||||
let rememberDevice = $state(false)
|
||||
let submitting = $state(false)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let hasPasskeys = $state(false)
|
||||
let hasTotp = $state(false)
|
||||
let passkeySupported = $state(false)
|
||||
let step = $state<'identifier' | 'password'>('identifier')
|
||||
|
||||
$effect(() => {
|
||||
passkeySupported = window.PublicKeyCredential !== undefined
|
||||
})
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getDelegatedDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return params.get('delegated_did')
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadDelegationInfo()
|
||||
})
|
||||
|
||||
async function loadDelegationInfo() {
|
||||
const requestUri = getRequestUri()
|
||||
delegatedDid = getDelegatedDid()
|
||||
|
||||
if (!requestUri || !delegatedDid) {
|
||||
error = $_('oauthDelegation.missingParams')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
delegatedHandle = data.handle || delegatedDid
|
||||
} else {
|
||||
const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`)
|
||||
if (handleResponse.ok) {
|
||||
const data = await handleResponse.json()
|
||||
delegatedHandle = data.handle || delegatedDid
|
||||
} else {
|
||||
delegatedHandle = delegatedDid
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
delegatedHandle = delegatedDid
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIdentifierSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!controllerIdentifier.trim()) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
let resolvedDid = controllerIdentifier.trim()
|
||||
if (!resolvedDid.startsWith('did:')) {
|
||||
resolvedDid = resolvedDid.replace(/^@/, '')
|
||||
const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(resolvedDid)}`)
|
||||
if (!response.ok) {
|
||||
error = $_('oauthDelegation.controllerNotFound')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
resolvedDid = data.did
|
||||
}
|
||||
|
||||
controllerDid = resolvedDid
|
||||
|
||||
const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`)
|
||||
if (securityResponse.ok) {
|
||||
const data = await securityResponse.json()
|
||||
hasPasskeys = passkeySupported && data.hasPasskeys === true
|
||||
hasTotp = data.hasTotp === true
|
||||
}
|
||||
|
||||
step = 'password'
|
||||
} catch {
|
||||
error = $_('oauthDelegation.controllerNotFound')
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
|
||||
const binary = atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64UrlToArrayBuffer(options.challenge),
|
||||
allowCredentials: options.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri || !controllerDid || !delegatedDid) {
|
||||
error = $_('oauthDelegation.missingInfo')
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const startResponse = await fetch('/oauth/passkey/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
identifier: controllerIdentifier.trim().replace(/^@/, '')
|
||||
})
|
||||
})
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const data = await startResponse.json()
|
||||
error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
const { options } = await startResponse.json()
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: prepareCredentialRequestOptions(options.publicKey)
|
||||
}) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
error = $_('oauthDelegation.passkeyCancelled')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
const assertionResponse = credential.response as AuthenticatorAssertionResponse
|
||||
const credentialData = {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferToBase64Url(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
|
||||
signature: arrayBufferToBase64Url(assertionResponse.signature),
|
||||
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
|
||||
}
|
||||
}
|
||||
|
||||
const finishResponse = await fetch('/oauth/passkey/finish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
identifier: controllerIdentifier.trim().replace(/^@/, ''),
|
||||
credential: credentialData,
|
||||
delegated_did: delegatedDid,
|
||||
controller_did: controllerDid
|
||||
})
|
||||
})
|
||||
|
||||
const data = await finishResponse.json()
|
||||
|
||||
if (!finishResponse.ok || data.success === false || data.error) {
|
||||
error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_totp) {
|
||||
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_2fa) {
|
||||
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
error = $_('oauthDelegation.unexpectedResponse')
|
||||
submitting = false
|
||||
} catch (e) {
|
||||
console.error('Passkey login error:', e)
|
||||
error = $_('oauthDelegation.authFailed')
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri || !controllerDid || !delegatedDid) {
|
||||
error = $_('oauthDelegation.missingInfo')
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/delegation/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
delegated_did: delegatedDid,
|
||||
controller_did: controllerDid,
|
||||
password,
|
||||
remember_device: rememberDevice
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || data.success === false || data.error) {
|
||||
error = data.error_description || data.error || $_('oauthDelegation.authFailed')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_totp) {
|
||||
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_2fa) {
|
||||
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
error = $_('oauthDelegation.unexpectedResponse')
|
||||
submitting = false
|
||||
} catch {
|
||||
error = $_('oauthDelegation.authFailed')
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/deny', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ request_uri: requestUri })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
}
|
||||
} catch {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = 'identifier'
|
||||
password = ''
|
||||
error = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="delegation-container">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<p>{$_('oauthDelegation.loading')}</p>
|
||||
</div>
|
||||
{:else if step === 'identifier'}
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauthDelegation.title')}</h1>
|
||||
<p class="subtitle">
|
||||
{$_('oauthDelegation.isDelegated', { values: { handle: delegatedHandle } })}
|
||||
<br />{$_('oauthDelegation.enterControllerHandle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleIdentifierSubmit}>
|
||||
<div class="field">
|
||||
<label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label>
|
||||
<input
|
||||
id="controller-identifier"
|
||||
type="text"
|
||||
bind:value={controllerIdentifier}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder={$_('oauthDelegation.handlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !controllerIdentifier.trim()}>
|
||||
{submitting ? $_('oauthDelegation.checking') : $_('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if step === 'password'}
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauthDelegation.signInAsController')}</h1>
|
||||
<p class="subtitle">
|
||||
{$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button class="back-link" onclick={goBack} disabled={submitting}>
|
||||
← {$_('oauthDelegation.useDifferentController')}
|
||||
</button>
|
||||
|
||||
<form onsubmit={handlePasswordSubmit}>
|
||||
{#if passkeySupported && hasPasskeys}
|
||||
<div class="auth-methods">
|
||||
<div class="passkey-method">
|
||||
<h3>{$_('oauthDelegation.signInWithPasskey')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="passkey-btn"
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
<path d="M17 17v4l3-2-3-2z" />
|
||||
<path d="M12 11c-4 0-6 2-6 4v4h9" />
|
||||
</svg>
|
||||
<span class="passkey-text">
|
||||
{submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauthDelegation.or')}</span>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauthDelegation.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauthDelegation.enterPassword')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauthDelegation.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !password}>
|
||||
{submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="password">{$_('oauthDelegation.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauthDelegation.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !password}>
|
||||
{submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauthDelegation.title')}</h1>
|
||||
</header>
|
||||
<div class="error">{error || $_('oauthDelegation.unableToLoad')}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel}>
|
||||
{$_('oauthDelegation.goBack')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.delegation-container {
|
||||
max-width: var(--width-md);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.back-link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.back-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.auth-methods {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.auth-methods {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.passkey-method,
|
||||
.password-method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.passkey-method h3,
|
||||
.password-method h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.method-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.method-divider {
|
||||
flex-direction: column;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.method-divider::before,
|
||||
.method-divider::after {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: var(--space-6);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.method-divider span {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.method-divider {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.method-divider::before,
|
||||
.method-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remember-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.remember-device input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--space-3);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.passkey-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.passkey-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.passkey-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.passkey-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,9 @@
|
||||
let error = $state<string | null>(null)
|
||||
let hasPasskeys = $state(false)
|
||||
let hasTotp = $state(false)
|
||||
let hasPassword = $state(true)
|
||||
let isDelegated = $state(false)
|
||||
let userDid = $state<string | null>(null)
|
||||
let checkingSecurityStatus = $state(false)
|
||||
let securityStatusChecked = $state(false)
|
||||
let passkeySupported = $state(false)
|
||||
@@ -84,11 +87,24 @@
|
||||
const data = await response.json()
|
||||
hasPasskeys = passkeySupported && data.hasPasskeys === true
|
||||
hasTotp = data.hasTotp === true
|
||||
hasPassword = data.hasPassword !== false
|
||||
isDelegated = data.isDelegated === true
|
||||
userDid = data.did || null
|
||||
securityStatusChecked = true
|
||||
|
||||
if (!hasPassword && !hasPasskeys && isDelegated && data.did) {
|
||||
const requestUri = getRequestUri()
|
||||
if (requestUri) {
|
||||
navigate(`/oauth/delegation?request_uri=${encodeURIComponent(requestUri)}&delegated_did=${encodeURIComponent(data.did)}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hasPasskeys = false
|
||||
hasTotp = false
|
||||
hasPassword = true
|
||||
isDelegated = false
|
||||
} finally {
|
||||
checkingSecurityStatus = false
|
||||
}
|
||||
|
||||
@@ -171,6 +171,17 @@ button.danger:hover:not(:disabled) {
|
||||
background: #900;
|
||||
}
|
||||
|
||||
button.danger-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
button.danger-outline:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-text);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
CREATE TYPE account_type AS ENUM ('personal', 'delegated');
|
||||
|
||||
ALTER TABLE users ADD COLUMN account_type account_type NOT NULL DEFAULT 'personal';
|
||||
|
||||
CREATE TYPE delegation_action_type AS ENUM (
|
||||
'grant_created',
|
||||
'grant_revoked',
|
||||
'scopes_modified',
|
||||
'token_issued',
|
||||
'repo_write',
|
||||
'blob_upload',
|
||||
'account_action'
|
||||
);
|
||||
|
||||
CREATE TABLE account_delegations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
delegated_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
|
||||
controller_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
|
||||
granted_scopes TEXT NOT NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
granted_by TEXT NOT NULL REFERENCES users(did),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by TEXT REFERENCES users(did),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX unique_active_delegation ON account_delegations(delegated_did, controller_did)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX idx_delegations_delegated ON account_delegations(delegated_did) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX idx_delegations_controller ON account_delegations(controller_did) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE delegation_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
delegated_did TEXT NOT NULL,
|
||||
actor_did TEXT NOT NULL,
|
||||
controller_did TEXT,
|
||||
action_type delegation_action_type NOT NULL,
|
||||
action_details JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_delegation_audit_delegated ON delegation_audit_log(delegated_did, created_at DESC);
|
||||
CREATE INDEX idx_delegation_audit_controller ON delegation_audit_log(controller_did, created_at DESC) WHERE controller_did IS NOT NULL;
|
||||
|
||||
ALTER TABLE oauth_authorization_request ADD COLUMN controller_did TEXT;
|
||||
|
||||
ALTER TABLE oauth_token ADD COLUMN controller_did TEXT;
|
||||
CREATE INDEX idx_oauth_token_controller ON oauth_token(controller_did) WHERE controller_did IS NOT NULL;
|
||||
|
||||
ALTER TABLE app_passwords ADD COLUMN created_by_controller_did TEXT REFERENCES users(did) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_app_passwords_controller ON app_passwords(created_by_controller_did) WHERE created_by_controller_did IS NOT NULL;
|
||||
|
||||
ALTER TABLE session_tokens ADD COLUMN controller_did TEXT;
|
||||
@@ -0,0 +1,976 @@
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::oauth::db as oauth_db;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::util::extract_client_ip;
|
||||
use crate::validation::is_valid_did;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jacquard::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListControllersResponse {
|
||||
pub controllers: Vec<ControllerInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let controllers = match delegation::get_delegations_for_account(&state.db, &auth.0.did).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list controllers: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to list controllers"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
Json(ListControllersResponse {
|
||||
controllers: controllers
|
||||
.into_iter()
|
||||
.map(|c| ControllerInfo {
|
||||
did: c.did,
|
||||
handle: c.handle,
|
||||
granted_scopes: c.granted_scopes,
|
||||
granted_at: c.granted_at,
|
||||
is_active: c.is_active,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddControllerInput {
|
||||
pub controller_did: String,
|
||||
pub granted_scopes: String,
|
||||
}
|
||||
|
||||
pub async fn add_controller(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<AddControllerInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let controller_exists: bool = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#,
|
||||
input.controller_did
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !controller_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "ControllerNotFound",
|
||||
"message": "Controller account not found"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match delegation::controls_any_accounts(&state.db, &auth.0.did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot add controllers to an account that controls other accounts"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check delegation status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify delegation status"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
|
||||
match delegation::has_any_controllers(&state.db, &input.controller_did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot add a controlled account as a controller"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify controller status"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
|
||||
match delegation::create_delegation(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&input.controller_did,
|
||||
&input.granted_scopes,
|
||||
&auth.0.did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&auth.0.did,
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(serde_json::json!({
|
||||
"granted_scopes": input.granted_scopes
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to add controller: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to add controller"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RemoveControllerInput {
|
||||
pub controller_did: String,
|
||||
}
|
||||
|
||||
pub async fn remove_controller(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RemoveControllerInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let revoked_app_passwords = sqlx::query_scalar!(
|
||||
r#"DELETE FROM app_passwords
|
||||
WHERE user_id = (SELECT id FROM users WHERE did = $1)
|
||||
AND created_by_controller_did = $2
|
||||
RETURNING id"#,
|
||||
auth.0.did,
|
||||
input.controller_did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map(|r| r.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let revoked_oauth_tokens = oauth_db::revoke_tokens_for_controller(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&input.controller_did,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&auth.0.did,
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::GrantRevoked,
|
||||
Some(serde_json::json!({
|
||||
"revoked_app_passwords": revoked_app_passwords,
|
||||
"revoked_oauth_tokens": revoked_oauth_tokens
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "DelegationNotFound",
|
||||
"message": "No active delegation found for this controller"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove controller: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to remove controller"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateControllerScopesInput {
|
||||
pub controller_did: String,
|
||||
pub granted_scopes: String,
|
||||
}
|
||||
|
||||
pub async fn update_controller_scopes(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<UpdateControllerScopesInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match delegation::update_delegation_scopes(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&input.controller_did,
|
||||
&input.granted_scopes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&auth.0.did,
|
||||
&auth.0.did,
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::ScopesModified,
|
||||
Some(serde_json::json!({
|
||||
"new_scopes": input.granted_scopes
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "DelegationNotFound",
|
||||
"message": "No active delegation found for this controller"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update controller scopes: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to update controller scopes"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListControlledAccountsResponse {
|
||||
pub accounts: Vec<DelegatedAccountInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_controlled_accounts(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let accounts = match delegation::get_accounts_controlled_by(&state.db, &auth.0.did).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list controlled accounts: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to list controlled accounts"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
Json(ListControlledAccountsResponse {
|
||||
accounts: accounts
|
||||
.into_iter()
|
||||
.map(|a| DelegatedAccountInfo {
|
||||
did: a.did,
|
||||
handle: a.handle,
|
||||
granted_scopes: a.granted_scopes,
|
||||
granted_at: a.granted_at,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditLogParams {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: String,
|
||||
pub delegated_did: String,
|
||||
pub actor_did: String,
|
||||
pub controller_did: Option<String>,
|
||||
pub action_type: String,
|
||||
pub action_details: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetAuditLogResponse {
|
||||
pub entries: Vec<AuditLogEntry>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
pub async fn get_audit_log(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Query(params): Query<AuditLogParams>,
|
||||
) -> Response {
|
||||
let limit = params.limit.min(100).max(1);
|
||||
let offset = params.offset.max(0);
|
||||
|
||||
let entries =
|
||||
match delegation::audit::get_audit_log_for_account(&state.db, &auth.0.did, limit, offset)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get audit log: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to get audit log"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let total = match delegation::audit::count_audit_log_entries(&state.db, &auth.0.did).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => 0,
|
||||
};
|
||||
|
||||
Json(GetAuditLogResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|e| AuditLogEntry {
|
||||
id: e.id.to_string(),
|
||||
delegated_did: e.delegated_did,
|
||||
actor_did: e.actor_did,
|
||||
controller_did: e.controller_did,
|
||||
action_type: format!("{:?}", e.action_type),
|
||||
action_details: e.action_details,
|
||||
created_at: e.created_at,
|
||||
})
|
||||
.collect(),
|
||||
total,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScopePresetInfo {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
pub description: &'static str,
|
||||
pub scopes: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetScopePresetsResponse {
|
||||
pub presets: Vec<ScopePresetInfo>,
|
||||
}
|
||||
|
||||
pub async fn get_scope_presets() -> Response {
|
||||
Json(GetScopePresetsResponse {
|
||||
presets: delegation::SCOPE_PRESETS
|
||||
.iter()
|
||||
.map(|p| ScopePresetInfo {
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
scopes: p.scopes,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDelegatedAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub controller_scopes: String,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDelegatedAccountResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn create_delegated_account(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<CreateDelegatedAccountInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many account creation attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match delegation::has_any_controllers(&state.db, &auth.0.did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot create delegated accounts from a controlled account"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify controller status"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_suffix = format!(".{}", hostname);
|
||||
|
||||
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
|
||||
input
|
||||
.handle
|
||||
.strip_suffix(&pds_suffix)
|
||||
.unwrap_or(&input.handle)
|
||||
} else {
|
||||
&input.handle
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, hostname),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
input.handle.to_lowercase()
|
||||
};
|
||||
|
||||
let email = input
|
||||
.email
|
||||
.as_ref()
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty());
|
||||
if let Some(ref email) = email
|
||||
&& !crate::api::validation::is_valid_email(email)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
let valid = sqlx::query_scalar!(
|
||||
"SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(Some(false));
|
||||
|
||||
if valid != Some(true) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if invite_required {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
use k256::ecdsa::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let secret_key = k256::SecretKey::random(&mut OsRng);
|
||||
let secret_key_bytes = secret_key.to_bytes().to_vec();
|
||||
|
||||
let signing_key = match SigningKey::from_slice(&secret_key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error creating signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
|
||||
|
||||
let genesis_result = match crate::plc::create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to create PLC operation"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let plc_client = crate::plc::PlcClient::new(None);
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did = genesis_result.did;
|
||||
info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account");
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Error starting transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash, password_required,
|
||||
account_type, preferred_comms_channel
|
||||
) VALUES ($1, $2, $3, NULL, FALSE, 'delegated'::account_type, 'email'::comms_channel) RETURNING id"#,
|
||||
)
|
||||
.bind(&handle)
|
||||
.bind(&email)
|
||||
.bind(&did)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
let user_id = match user_insert {
|
||||
Ok((id,)) => id,
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error()
|
||||
&& db_err.code().as_deref() == Some("23505")
|
||||
{
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
} else if constraint.contains("email") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidEmail", "message": "Email already registered"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Error encrypting signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
|
||||
user_id,
|
||||
&encrypted_key_bytes[..],
|
||||
crate::config::ENCRYPTION_VERSION
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
|
||||
VALUES ($1, $2, $3, $4)"#,
|
||||
did,
|
||||
auth.0.did,
|
||||
input.controller_scopes,
|
||||
auth.0.did
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Error creating initial delegation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mst = Mst::new(Arc::new(state.block_store.clone()));
|
||||
let mst_root = match mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error persisting MST: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error saving genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)",
|
||||
user_id,
|
||||
commit_cid_str
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Error inserting repo: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
&auth.0.did,
|
||||
Some(&auth.0.did),
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(json!({
|
||||
"account_created": true,
|
||||
"granted_scopes": input.controller_scopes
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created");
|
||||
|
||||
Json(CreateDelegatedAccountResponse { did, handle }).into_response()
|
||||
}
|
||||
+5
-1
@@ -42,6 +42,7 @@ pub enum ApiError {
|
||||
AppPasswordNotFound,
|
||||
InvalidSwap,
|
||||
Forbidden,
|
||||
InsufficientScope,
|
||||
InvitesDisabled,
|
||||
DatabaseError,
|
||||
UpstreamFailure,
|
||||
@@ -72,7 +73,9 @@ impl ApiError {
|
||||
| Self::TokenRequired
|
||||
| Self::AccountDeactivated
|
||||
| Self::AccountTakedown => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden | Self::InvitesDisabled => StatusCode::FORBIDDEN,
|
||||
Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
Self::AccountNotFound
|
||||
| Self::RepoNotFound
|
||||
| Self::RepoNotFoundMsg(_)
|
||||
@@ -114,6 +117,7 @@ impl ApiError {
|
||||
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
||||
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
||||
Self::Forbidden => Cow::Borrowed("Forbidden"),
|
||||
Self::InsufficientScope => Cow::Borrowed("InsufficientScope"),
|
||||
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
|
||||
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
|
||||
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod actor;
|
||||
pub mod admin;
|
||||
pub mod delegation;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod moderation;
|
||||
|
||||
+24
-3
@@ -1,4 +1,5 @@
|
||||
use crate::auth::{ServiceTokenVerifier, is_service_token};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::{
|
||||
@@ -39,7 +40,7 @@ pub async fn upload_blob(
|
||||
|
||||
let is_service_auth = is_service_token(&token);
|
||||
|
||||
let (did, is_migration) = if is_service_auth {
|
||||
let (did, is_migration, controller_did) = if is_service_auth {
|
||||
debug!("Verifying service token for blob upload");
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
match verifier
|
||||
@@ -48,7 +49,7 @@ pub async fn upload_blob(
|
||||
{
|
||||
Ok(claims) => {
|
||||
debug!("Service token verified for DID: {}", claims.iss);
|
||||
(claims.iss, false)
|
||||
(claims.iss, false, None)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service token verification failed: {:?}", e);
|
||||
@@ -82,7 +83,8 @@ pub async fn upload_blob(
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
(user.did, deactivated.is_some())
|
||||
let ctrl_did = user.controller_did.clone();
|
||||
(user.did, deactivated.is_some(), ctrl_did)
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
@@ -204,6 +206,25 @@ pub async fn upload_blob(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::BlobUpload,
|
||||
Some(json!({
|
||||
"cid": cid_str,
|
||||
"mime_type": mime_type,
|
||||
"size": size
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Json(json!({
|
||||
"blob": {
|
||||
"$type": "blob",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::validation::validate_record;
|
||||
use super::write::has_verified_comms_channel;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -109,6 +110,7 @@ pub async fn apply_writes(
|
||||
let did = auth_user.did.clone();
|
||||
let is_oauth = auth_user.is_oauth;
|
||||
let scope = auth_user.scope;
|
||||
let controller_did = auth_user.controller_did.clone();
|
||||
if input.repo != did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -116,26 +118,21 @@ pub async fn apply_writes(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match has_verified_comms_channel(&state.db, &did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error checking notification channels: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let is_delegated = crate::delegation::is_delegated_account(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if input.writes.is_empty() {
|
||||
return (
|
||||
@@ -485,6 +482,51 @@ pub async fn apply_writes(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let write_summary: Vec<serde_json::Value> = input
|
||||
.writes
|
||||
.iter()
|
||||
.map(|w| match w {
|
||||
WriteOp::Create {
|
||||
collection, rkey, ..
|
||||
} => json!({
|
||||
"action": "create",
|
||||
"collection": collection,
|
||||
"rkey": rkey
|
||||
}),
|
||||
WriteOp::Update {
|
||||
collection, rkey, ..
|
||||
} => json!({
|
||||
"action": "update",
|
||||
"collection": collection,
|
||||
"rkey": rkey
|
||||
}),
|
||||
WriteOp::Delete { collection, rkey } => json!({
|
||||
"action": "delete",
|
||||
"collection": collection,
|
||||
"rkey": rkey
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::RepoWrite,
|
||||
Some(json!({
|
||||
"action": "apply_writes",
|
||||
"count": input.writes.len(),
|
||||
"writes": write_summary
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ApplyWritesOutput {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::api::repo::record::write::prepare_repo_write;
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -52,6 +53,7 @@ pub async fn delete_record(
|
||||
let did = auth.did;
|
||||
let user_id = auth.user_id;
|
||||
let current_root_cid = auth.current_root_cid;
|
||||
let controller_did = auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
@@ -124,6 +126,8 @@ pub async fn delete_record(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let collection_for_audit = input.collection.clone();
|
||||
let rkey_for_audit = input.rkey.clone();
|
||||
let op = RecordOp::Delete {
|
||||
collection: input.collection,
|
||||
rkey: input.rkey,
|
||||
@@ -174,5 +178,24 @@ pub async fn delete_record(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::RepoWrite,
|
||||
Some(json!({
|
||||
"action": "delete",
|
||||
"collection": collection_for_audit,
|
||||
"rkey": rkey_for_audit
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::validation::validate_record;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -55,6 +56,7 @@ pub struct RepoWriteAuth {
|
||||
pub current_root_cid: Cid,
|
||||
pub is_oauth: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn prepare_repo_write(
|
||||
@@ -99,26 +101,21 @@ pub async fn prepare_repo_write(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
match has_verified_comms_channel(&state.db, &auth_user.did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error checking notification channels: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let is_delegated = crate::delegation::is_delegated_account(&state.db, &auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -172,6 +169,7 @@ pub async fn prepare_repo_write(
|
||||
current_root_cid,
|
||||
is_oauth: auth_user.is_oauth,
|
||||
scope: auth_user.scope,
|
||||
controller_did: auth_user.controller_did,
|
||||
})
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
@@ -215,6 +213,7 @@ pub async fn create_record(
|
||||
let did = auth.did;
|
||||
let user_id = auth.user_id;
|
||||
let current_root_cid = auth.current_root_cid;
|
||||
let controller_did = auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
@@ -355,6 +354,25 @@ pub async fn create_record(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::RepoWrite,
|
||||
Some(json!({
|
||||
"action": "create",
|
||||
"collection": input.collection,
|
||||
"rkey": rkey
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateRecordOutput {
|
||||
@@ -415,6 +433,7 @@ pub async fn put_record(
|
||||
let did = auth.did;
|
||||
let user_id = auth.user_id;
|
||||
let current_root_cid = auth.current_root_cid;
|
||||
let controller_did = auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
@@ -562,6 +581,7 @@ pub async fn put_record(
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let is_update = existing_cid.is_some();
|
||||
if let Err(e) = commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
@@ -582,6 +602,25 @@ pub async fn put_record(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::RepoWrite,
|
||||
Some(json!({
|
||||
"action": if is_update { "update" } else { "create" },
|
||||
"collection": input.collection,
|
||||
"rkey": input.rkey
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(PutRecordOutput {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::util::get_user_id_by_did;
|
||||
use axum::{
|
||||
@@ -20,6 +21,8 @@ pub struct AppPassword {
|
||||
pub privileged: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by_controller: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -36,7 +39,7 @@ pub async fn list_app_passwords(
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
match sqlx::query!(
|
||||
"SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
"SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
user_id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
@@ -50,6 +53,7 @@ pub async fn list_app_passwords(
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
privileged: row.privileged,
|
||||
scopes: row.scopes.clone(),
|
||||
created_by_controller: row.created_by_controller_did.clone(),
|
||||
})
|
||||
.collect();
|
||||
Json(ListAppPasswordsOutput { passwords }).into_response()
|
||||
@@ -118,6 +122,31 @@ pub async fn create_app_password(
|
||||
if let Ok(Some(_)) = existing {
|
||||
return ApiError::DuplicateAppPassword.into_response();
|
||||
}
|
||||
|
||||
let (final_scopes, controller_did) = if let Some(ref controller) = auth_user.controller_did {
|
||||
let grant = delegation::get_delegation(&state.db, &auth_user.did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
|
||||
|
||||
let requested = input.scopes.as_deref().unwrap_or("atproto");
|
||||
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
|
||||
|
||||
if intersected.is_empty() && !granted_scopes.is_empty() {
|
||||
return ApiError::InsufficientScope.into_response();
|
||||
}
|
||||
|
||||
let scope_result = if intersected.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(intersected)
|
||||
};
|
||||
(scope_result, Some(controller.clone()))
|
||||
} else {
|
||||
(input.scopes.clone(), None)
|
||||
};
|
||||
|
||||
let password: String = (0..4)
|
||||
.map(|_| {
|
||||
use rand::Rng;
|
||||
@@ -137,28 +166,47 @@ pub async fn create_app_password(
|
||||
}
|
||||
};
|
||||
let privileged = input.privileged.unwrap_or(false);
|
||||
let scopes = input.scopes.clone();
|
||||
let created_at = chrono::Utc::now();
|
||||
match sqlx::query!(
|
||||
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
user_id,
|
||||
name,
|
||||
password_hash,
|
||||
created_at,
|
||||
privileged,
|
||||
scopes
|
||||
final_scopes,
|
||||
controller_did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(CreateAppPasswordOutput {
|
||||
name: name.to_string(),
|
||||
password,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
privileged,
|
||||
scopes,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(_) => {
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&auth_user.did,
|
||||
controller,
|
||||
Some(controller),
|
||||
DelegationActionType::AccountAction,
|
||||
Some(json!({
|
||||
"action": "create_app_password",
|
||||
"name": name,
|
||||
"scopes": final_scopes
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Json(CreateAppPasswordOutput {
|
||||
name: name.to_string(),
|
||||
password,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
privileged,
|
||||
scopes: final_scopes,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error creating app password: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
|
||||
@@ -97,6 +97,7 @@ pub async fn get_service_auth(
|
||||
is_admin: false,
|
||||
scope: result.scope,
|
||||
key_bytes: None,
|
||||
controller_did: None,
|
||||
},
|
||||
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
|
||||
return (
|
||||
|
||||
+21
-11
@@ -125,16 +125,16 @@ pub async fn create_session(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let (password_valid, app_password_scopes) = if row
|
||||
let (password_valid, app_password_scopes, app_password_controller) = if row
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.map(|h| verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
(true, None)
|
||||
(true, None, None)
|
||||
} else {
|
||||
let app_passwords = sqlx::query!(
|
||||
"SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
|
||||
"SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
|
||||
row.id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
@@ -144,8 +144,12 @@ pub async fn create_session(
|
||||
.iter()
|
||||
.find(|app| verify(&input.password, &app.password_hash).unwrap_or(false));
|
||||
match matched {
|
||||
Some(app) => (true, app.scopes.clone()),
|
||||
None => (false, None),
|
||||
Some(app) => (
|
||||
true,
|
||||
app.scopes.clone(),
|
||||
app.created_by_controller_did.clone(),
|
||||
),
|
||||
None => (false, None, None),
|
||||
}
|
||||
};
|
||||
if !password_valid {
|
||||
@@ -155,7 +159,10 @@ pub async fn create_session(
|
||||
}
|
||||
let is_verified =
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
if !is_verified {
|
||||
let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -181,10 +188,11 @@ pub async fn create_session(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let access_meta = match crate::auth::create_access_token_with_scope_metadata(
|
||||
let access_meta = match crate::auth::create_access_token_with_delegation(
|
||||
&row.did,
|
||||
&key_bytes,
|
||||
app_password_scopes.as_deref(),
|
||||
app_password_controller.as_deref(),
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -200,7 +208,7 @@ pub async fn create_session(
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
row.did,
|
||||
access_meta.jti,
|
||||
refresh_meta.jti,
|
||||
@@ -208,7 +216,8 @@ pub async fn create_session(
|
||||
refresh_meta.expires_at,
|
||||
is_legacy_login,
|
||||
false,
|
||||
app_password_scopes
|
||||
app_password_scopes,
|
||||
app_password_controller
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -397,7 +406,7 @@ pub async fn refresh_session(
|
||||
.into_response();
|
||||
}
|
||||
let session_row = match sqlx::query!(
|
||||
r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version
|
||||
r#"SELECT st.id, st.did, st.scope, st.controller_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
|
||||
@@ -429,10 +438,11 @@ pub async fn refresh_session(
|
||||
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
|
||||
}
|
||||
let new_access_meta = match crate::auth::create_access_token_with_scope_metadata(
|
||||
let new_access_meta = match crate::auth::create_access_token_with_delegation(
|
||||
&session_row.did,
|
||||
&key_bytes,
|
||||
session_row.scope.as_deref(),
|
||||
session_row.controller_did.as_deref(),
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
|
||||
+15
-2
@@ -24,8 +24,9 @@ pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
|
||||
pub use token::{
|
||||
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
|
||||
create_access_token_with_metadata, create_access_token_with_scope_metadata,
|
||||
create_refresh_token, create_refresh_token_with_metadata, create_service_token,
|
||||
create_access_token_with_delegation, create_access_token_with_metadata,
|
||||
create_access_token_with_scope_metadata, create_refresh_token,
|
||||
create_refresh_token_with_metadata, create_service_token,
|
||||
};
|
||||
pub use verify::{
|
||||
TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token,
|
||||
@@ -62,6 +63,7 @@ pub struct AuthenticatedUser {
|
||||
pub is_oauth: bool,
|
||||
pub is_admin: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
@@ -249,12 +251,14 @@ async fn validate_bearer_token_with_options_internal(
|
||||
}
|
||||
|
||||
if session_valid {
|
||||
let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone());
|
||||
return Ok(AuthenticatedUser {
|
||||
did: did.clone(),
|
||||
key_bytes: Some(decrypted_key),
|
||||
is_oauth: false,
|
||||
is_admin,
|
||||
scope: token_data.claims.scope.clone(),
|
||||
controller_did,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -304,6 +308,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
is_oauth: true,
|
||||
is_admin: oauth_token.is_admin,
|
||||
scope: oauth_info.scope,
|
||||
controller_did: oauth_info.controller_did,
|
||||
});
|
||||
} else {
|
||||
return Err(TokenValidationError::TokenExpired);
|
||||
@@ -378,12 +383,18 @@ pub async fn validate_token_with_dpop(
|
||||
is_oauth: true,
|
||||
is_admin: user_info.is_admin,
|
||||
scope: result.scope,
|
||||
controller_did: None,
|
||||
})
|
||||
}
|
||||
Err(_) => Err(TokenValidationError::AuthenticationFailed),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActClaim {
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub iss: String,
|
||||
@@ -396,6 +407,8 @@ pub struct Claims {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lxm: Option<String>,
|
||||
pub jti: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub act: Option<ActClaim>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
||||
+34
-1
@@ -1,4 +1,4 @@
|
||||
use super::{Claims, Header};
|
||||
use super::{ActClaim, Claims, Header};
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
@@ -51,6 +51,24 @@ pub fn create_access_token_with_scope_metadata(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_delegation(
|
||||
did: &str,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let scope = scopes.unwrap_or(SCOPE_ACCESS);
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
|
||||
create_signed_token_with_act(
|
||||
did,
|
||||
scope,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
act,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_with_metadata(
|
||||
did: &str,
|
||||
key_bytes: &[u8],
|
||||
@@ -81,6 +99,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
act: None,
|
||||
};
|
||||
|
||||
sign_claims(claims, &signing_key)
|
||||
@@ -92,6 +111,17 @@ fn create_signed_token_with_metadata(
|
||||
typ: &str,
|
||||
key_bytes: &[u8],
|
||||
duration: Duration,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_act(did, scope, typ, key_bytes, duration, None)
|
||||
}
|
||||
|
||||
fn create_signed_token_with_act(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
typ: &str,
|
||||
key_bytes: &[u8],
|
||||
duration: Duration,
|
||||
act: Option<ActClaim>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
@@ -114,6 +144,7 @@ fn create_signed_token_with_metadata(
|
||||
scope: Some(scope.to_string()),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
act,
|
||||
};
|
||||
|
||||
let token = sign_claims_with_type(claims, &signing_key, typ)?;
|
||||
@@ -202,6 +233,7 @@ pub fn create_service_token_hs256(
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
act: None,
|
||||
};
|
||||
|
||||
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
|
||||
@@ -233,6 +265,7 @@ fn create_hs256_token_with_metadata(
|
||||
scope: Some(scope.to_string()),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
act: None,
|
||||
};
|
||||
|
||||
let token = sign_claims_hs256(claims, typ, secret)?;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")]
|
||||
pub enum DelegationActionType {
|
||||
GrantCreated,
|
||||
GrantRevoked,
|
||||
ScopesModified,
|
||||
TokenIssued,
|
||||
RepoWrite,
|
||||
BlobUpload,
|
||||
AccountAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: Uuid,
|
||||
pub delegated_did: String,
|
||||
pub actor_did: String,
|
||||
pub controller_did: Option<String>,
|
||||
pub action_type: DelegationActionType,
|
||||
pub action_details: Option<serde_json::Value>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn log_delegation_action(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
actor_did: &str,
|
||||
controller_did: Option<&str>,
|
||||
action_type: DelegationActionType,
|
||||
action_details: Option<serde_json::Value>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let id = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO delegation_audit_log
|
||||
(delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
"#,
|
||||
delegated_did,
|
||||
actor_did,
|
||||
controller_did,
|
||||
action_type as DelegationActionType,
|
||||
action_details,
|
||||
ip_address,
|
||||
user_agent
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get_audit_log_for_account(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>, sqlx::Error> {
|
||||
let entries = sqlx::query_as!(
|
||||
AuditLogEntry,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
delegated_did,
|
||||
actor_did,
|
||||
controller_did,
|
||||
action_type as "action_type: DelegationActionType",
|
||||
action_details,
|
||||
ip_address,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM delegation_audit_log
|
||||
WHERE delegated_did = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
delegated_did,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub async fn get_audit_log_by_controller(
|
||||
pool: &PgPool,
|
||||
controller_did: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>, sqlx::Error> {
|
||||
let entries = sqlx::query_as!(
|
||||
AuditLogEntry,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
delegated_did,
|
||||
actor_did,
|
||||
controller_did,
|
||||
action_type as "action_type: DelegationActionType",
|
||||
action_details,
|
||||
ip_address,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM delegation_audit_log
|
||||
WHERE controller_did = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
controller_did,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub async fn count_audit_log_entries(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#,
|
||||
delegated_did
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationGrant {
|
||||
pub id: Uuid,
|
||||
pub delegated_did: String,
|
||||
pub controller_did: String,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub granted_by: String,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub revoked_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControllerInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
pub async fn is_delegated_account(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT account_type::text = 'delegated' as "is_delegated!" FROM users WHERE did = $1"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn create_delegation(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
controller_did: &str,
|
||||
granted_scopes: &str,
|
||||
granted_by: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let id = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
"#,
|
||||
delegated_did,
|
||||
controller_did,
|
||||
granted_scopes,
|
||||
granted_by
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn revoke_delegation(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
controller_did: &str,
|
||||
revoked_by: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE account_delegations
|
||||
SET revoked_at = NOW(), revoked_by = $1
|
||||
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
|
||||
"#,
|
||||
revoked_by,
|
||||
delegated_did,
|
||||
controller_did
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn update_delegation_scopes(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
controller_did: &str,
|
||||
new_scopes: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE account_delegations
|
||||
SET granted_scopes = $1
|
||||
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
|
||||
"#,
|
||||
new_scopes,
|
||||
delegated_did,
|
||||
controller_did
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn get_delegation(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
controller_did: &str,
|
||||
) -> Result<Option<DelegationGrant>, sqlx::Error> {
|
||||
let grant = sqlx::query_as!(
|
||||
DelegationGrant,
|
||||
r#"
|
||||
SELECT id, delegated_did, controller_did, granted_scopes,
|
||||
granted_at, granted_by, revoked_at, revoked_by
|
||||
FROM account_delegations
|
||||
WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL
|
||||
"#,
|
||||
delegated_did,
|
||||
controller_did
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
pub async fn get_delegations_for_account(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
) -> Result<Vec<ControllerInfo>, sqlx::Error> {
|
||||
let controllers = sqlx::query_as!(
|
||||
ControllerInfo,
|
||||
r#"
|
||||
SELECT
|
||||
u.did,
|
||||
u.handle,
|
||||
d.granted_scopes,
|
||||
d.granted_at,
|
||||
(u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!"
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.controller_did
|
||||
WHERE d.delegated_did = $1 AND d.revoked_at IS NULL
|
||||
ORDER BY d.granted_at DESC
|
||||
"#,
|
||||
delegated_did
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(controllers)
|
||||
}
|
||||
|
||||
pub async fn get_accounts_controlled_by(
|
||||
pool: &PgPool,
|
||||
controller_did: &str,
|
||||
) -> Result<Vec<DelegatedAccountInfo>, sqlx::Error> {
|
||||
let accounts = sqlx::query_as!(
|
||||
DelegatedAccountInfo,
|
||||
r#"
|
||||
SELECT
|
||||
u.did,
|
||||
u.handle,
|
||||
d.granted_scopes,
|
||||
d.granted_at
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.delegated_did
|
||||
WHERE d.controller_did = $1
|
||||
AND d.revoked_at IS NULL
|
||||
AND u.deactivated_at IS NULL
|
||||
AND u.takedown_ref IS NULL
|
||||
ORDER BY d.granted_at DESC
|
||||
"#,
|
||||
controller_did
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
pub async fn get_active_controllers_for_account(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
) -> Result<Vec<ControllerInfo>, sqlx::Error> {
|
||||
let controllers = sqlx::query_as!(
|
||||
ControllerInfo,
|
||||
r#"
|
||||
SELECT
|
||||
u.did,
|
||||
u.handle,
|
||||
d.granted_scopes,
|
||||
d.granted_at,
|
||||
true as "is_active!"
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.controller_did
|
||||
WHERE d.delegated_did = $1
|
||||
AND d.revoked_at IS NULL
|
||||
AND u.deactivated_at IS NULL
|
||||
AND u.takedown_ref IS NULL
|
||||
ORDER BY d.granted_at DESC
|
||||
"#,
|
||||
delegated_did
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(controllers)
|
||||
}
|
||||
|
||||
pub async fn count_active_controllers(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT COUNT(*) as "count!"
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.controller_did
|
||||
WHERE d.delegated_did = $1
|
||||
AND d.revoked_at IS NULL
|
||||
AND u.deactivated_at IS NULL
|
||||
AND u.takedown_ref IS NULL
|
||||
"#,
|
||||
delegated_did
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn has_any_controllers(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM account_delegations
|
||||
WHERE delegated_did = $1 AND revoked_at IS NULL
|
||||
) as "exists!""#,
|
||||
did
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub async fn controls_any_accounts(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM account_delegations
|
||||
WHERE controller_did = $1 AND revoked_at IS NULL
|
||||
) as "exists!""#,
|
||||
did
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod audit;
|
||||
pub mod db;
|
||||
pub mod scopes;
|
||||
|
||||
pub use audit::{DelegationActionType, log_delegation_action};
|
||||
pub use db::{
|
||||
DelegationGrant, controls_any_accounts, create_delegation, get_accounts_controlled_by,
|
||||
get_delegation, get_delegations_for_account, has_any_controllers, is_delegated_account,
|
||||
revoke_delegation, update_delegation_scopes,
|
||||
};
|
||||
pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes};
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct ScopePreset {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
pub description: &'static str,
|
||||
pub scopes: &'static str,
|
||||
}
|
||||
|
||||
pub const SCOPE_PRESETS: &[ScopePreset] = &[
|
||||
ScopePreset {
|
||||
name: "owner",
|
||||
label: "Owner",
|
||||
description: "Full control including delegation management",
|
||||
scopes: "atproto",
|
||||
},
|
||||
ScopePreset {
|
||||
name: "admin",
|
||||
label: "Admin",
|
||||
description: "Manage account settings, post content, upload media",
|
||||
scopes: "atproto repo:* blob:*/* account:*?action=manage",
|
||||
},
|
||||
ScopePreset {
|
||||
name: "editor",
|
||||
label: "Editor",
|
||||
description: "Post content and upload media",
|
||||
scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
|
||||
},
|
||||
ScopePreset {
|
||||
name: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Read-only access",
|
||||
scopes: "",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
|
||||
if granted.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
|
||||
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
|
||||
|
||||
let granted_has_atproto = granted_set.contains("atproto");
|
||||
let requested_has_atproto = requested_set.contains("atproto");
|
||||
|
||||
if granted_has_atproto && requested_has_atproto {
|
||||
return "atproto".to_string();
|
||||
}
|
||||
|
||||
if granted_has_atproto {
|
||||
return requested_set.into_iter().collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
|
||||
if requested_has_atproto {
|
||||
return granted_set.into_iter().collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
|
||||
let mut result: Vec<&str> = Vec::new();
|
||||
|
||||
for requested_scope in &requested_set {
|
||||
if granted_set.contains(requested_scope) {
|
||||
result.push(requested_scope);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(match_result) = find_matching_scope(requested_scope, &granted_set) {
|
||||
result.push(match_result);
|
||||
}
|
||||
}
|
||||
|
||||
result.sort();
|
||||
result.join(" ")
|
||||
}
|
||||
|
||||
fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> {
|
||||
for granted_scope in granted {
|
||||
if scopes_compatible(granted_scope, requested) {
|
||||
return Some(granted_scope);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn scopes_compatible(granted: &str, requested: &str) -> bool {
|
||||
if granted == requested {
|
||||
return true;
|
||||
}
|
||||
|
||||
let (granted_base, _granted_params) = split_scope(granted);
|
||||
let (requested_base, _requested_params) = split_scope(requested);
|
||||
|
||||
if granted_base.ends_with(":*")
|
||||
&& requested_base.starts_with(&granted_base[..granted_base.len() - 1])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if granted_base.ends_with(".*") {
|
||||
let prefix = &granted_base[..granted_base.len() - 2];
|
||||
if requested_base.starts_with(prefix) && requested_base.len() > prefix.len() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn split_scope(scope: &str) -> (&str, Option<&str>) {
|
||||
if let Some(idx) = scope.find('?') {
|
||||
(&scope[..idx], Some(&scope[idx + 1..]))
|
||||
} else {
|
||||
(scope, None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> {
|
||||
if scopes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for scope in scopes.split_whitespace() {
|
||||
let (base, _) = split_scope(scope);
|
||||
|
||||
if !is_valid_scope_prefix(base) {
|
||||
return Err(format!("Invalid scope: {}", scope));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_valid_scope_prefix(base: &str) -> bool {
|
||||
let valid_prefixes = [
|
||||
"atproto",
|
||||
"repo:",
|
||||
"blob:",
|
||||
"rpc:",
|
||||
"account:",
|
||||
"identity:",
|
||||
"transition:",
|
||||
];
|
||||
|
||||
for prefix in valid_prefixes {
|
||||
if base == prefix.trim_end_matches(':') || base.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_intersect_both_atproto() {
|
||||
assert_eq!(intersect_scopes("atproto", "atproto"), "atproto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_granted_atproto() {
|
||||
let result = intersect_scopes("repo:* blob:*/*", "atproto");
|
||||
assert!(result.contains("repo:*"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_requested_atproto() {
|
||||
let result = intersect_scopes("atproto", "repo:* blob:*/*");
|
||||
assert!(result.contains("repo:*"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_exact_match() {
|
||||
assert_eq!(
|
||||
intersect_scopes("repo:*?action=create", "repo:*?action=create"),
|
||||
"repo:*?action=create"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_empty_granted() {
|
||||
assert_eq!(intersect_scopes("atproto", ""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_scopes_valid() {
|
||||
assert!(validate_delegation_scopes("atproto").is_ok());
|
||||
assert!(validate_delegation_scopes("repo:* blob:*/*").is_ok());
|
||||
assert!(validate_delegation_scopes("").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_scopes_invalid() {
|
||||
assert!(validate_delegation_scopes("invalid:scope").is_err());
|
||||
}
|
||||
}
|
||||
+41
@@ -6,6 +6,7 @@ pub mod circuit_breaker;
|
||||
pub mod comms;
|
||||
pub mod config;
|
||||
pub mod crawlers;
|
||||
pub mod delegation;
|
||||
pub mod handle;
|
||||
pub mod image;
|
||||
pub mod metrics;
|
||||
@@ -528,6 +529,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/oauth/authorize/consent",
|
||||
post(oauth::endpoints::consent_post),
|
||||
)
|
||||
.route(
|
||||
"/oauth/delegation/auth",
|
||||
post(oauth::endpoints::delegation_auth),
|
||||
)
|
||||
.route(
|
||||
"/oauth/delegation/totp",
|
||||
post(oauth::endpoints::delegation_totp_verify),
|
||||
)
|
||||
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
|
||||
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
|
||||
.route(
|
||||
@@ -562,6 +571,38 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.verifyToken",
|
||||
post(api::server::verify_token),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.listControllers",
|
||||
get(api::delegation::list_controllers),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.addController",
|
||||
post(api::delegation::add_controller),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.removeController",
|
||||
post(api::delegation::remove_controller),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.updateControllerScopes",
|
||||
post(api::delegation::update_controller_scopes),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.listControlledAccounts",
|
||||
get(api::delegation::list_controlled_accounts),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.getAuditLog",
|
||||
get(api::delegation::get_audit_log),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.getScopePresets",
|
||||
get(api::delegation::get_scope_presets),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.createDelegatedAccount",
|
||||
post(api::delegation::create_delegated_account),
|
||||
)
|
||||
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
|
||||
pub use request::{
|
||||
consume_authorization_request_by_code, create_authorization_request,
|
||||
delete_authorization_request, delete_expired_authorization_requests, get_authorization_request,
|
||||
mark_request_authenticated, set_authorization_did, update_authorization_request,
|
||||
update_request_scope,
|
||||
mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did,
|
||||
update_authorization_request, update_request_scope,
|
||||
};
|
||||
pub use scope_preference::{
|
||||
ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent,
|
||||
@@ -27,7 +27,7 @@ pub use token::{
|
||||
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
|
||||
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
|
||||
get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
|
||||
revoke_tokens_for_client, rotate_token,
|
||||
revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token,
|
||||
};
|
||||
pub use two_factor::{
|
||||
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
|
||||
|
||||
+38
-2
@@ -38,7 +38,7 @@ pub async fn get_authorization_request(
|
||||
) -> Result<Option<RequestData>, OAuthError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT did, device_id, client_id, client_auth, parameters, expires_at, code
|
||||
SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did
|
||||
FROM oauth_authorization_request
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -61,6 +61,7 @@ pub async fn get_authorization_request(
|
||||
did: r.did,
|
||||
device_id: r.device_id,
|
||||
code: r.code,
|
||||
controller_did: r.controller_did,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -119,7 +120,7 @@ pub async fn consume_authorization_request_by_code(
|
||||
r#"
|
||||
DELETE FROM oauth_authorization_request
|
||||
WHERE code = $1
|
||||
RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code
|
||||
RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did
|
||||
"#,
|
||||
code
|
||||
)
|
||||
@@ -140,6 +141,7 @@ pub async fn consume_authorization_request_by_code(
|
||||
did: r.did,
|
||||
device_id: r.device_id,
|
||||
code: r.code,
|
||||
controller_did: r.controller_did,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -212,3 +214,37 @@ pub async fn update_request_scope(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_controller_did(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
controller_did: &str,
|
||||
) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE oauth_authorization_request
|
||||
SET controller_did = $2
|
||||
WHERE id = $1
|
||||
"#,
|
||||
request_id,
|
||||
controller_did
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_request_did(pool: &PgPool, request_id: &str, did: &str) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE oauth_authorization_request
|
||||
SET did = $2
|
||||
WHERE id = $1
|
||||
"#,
|
||||
request_id,
|
||||
did
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+26
-6
@@ -10,8 +10,8 @@ pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthE
|
||||
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)
|
||||
device_id, parameters, details, code, current_refresh_token, scope, controller_did)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
"#,
|
||||
data.did,
|
||||
@@ -27,6 +27,7 @@ pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthE
|
||||
data.code,
|
||||
data.current_refresh_token,
|
||||
data.scope,
|
||||
data.controller_did,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
@@ -40,7 +41,7 @@ pub async fn get_token_by_id(
|
||||
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
|
||||
device_id, parameters, details, code, current_refresh_token, scope, controller_did
|
||||
FROM oauth_token
|
||||
WHERE token_id = $1
|
||||
"#,
|
||||
@@ -63,6 +64,7 @@ pub async fn get_token_by_id(
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -75,7 +77,7 @@ pub async fn get_token_by_refresh_token(
|
||||
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
|
||||
device_id, parameters, details, code, current_refresh_token, scope, controller_did
|
||||
FROM oauth_token
|
||||
WHERE current_refresh_token = $1
|
||||
"#,
|
||||
@@ -100,6 +102,7 @@ pub async fn get_token_by_refresh_token(
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -178,7 +181,7 @@ pub async fn get_token_by_previous_refresh_token(
|
||||
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
|
||||
device_id, parameters, details, code, current_refresh_token, scope, controller_did
|
||||
FROM oauth_token
|
||||
WHERE previous_refresh_token = $1 AND rotated_at > $2
|
||||
"#,
|
||||
@@ -204,6 +207,7 @@ pub async fn get_token_by_previous_refresh_token(
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -238,7 +242,7 @@ pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result<Vec<TokenD
|
||||
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
|
||||
device_id, parameters, details, code, current_refresh_token, scope, controller_did
|
||||
FROM oauth_token
|
||||
WHERE did = $1
|
||||
"#,
|
||||
@@ -262,6 +266,7 @@ pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result<Vec<TokenD
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
});
|
||||
}
|
||||
Ok(tokens)
|
||||
@@ -327,3 +332,18 @@ pub async fn revoke_tokens_for_client(
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn revoke_tokens_for_controller(
|
||||
pool: &PgPool,
|
||||
delegated_did: &str,
|
||||
controller_did: &str,
|
||||
) -> Result<u64, OAuthError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2",
|
||||
delegated_did,
|
||||
controller_did
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
@@ -204,6 +204,55 @@ pub async fn authorize_get(
|
||||
.into_response();
|
||||
}
|
||||
let force_new_account = query.new_account.unwrap_or(false);
|
||||
|
||||
if let Some(ref login_hint) = request_data.parameters.login_hint {
|
||||
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized = if login_hint.contains('@') || login_hint.starts_with("did:") {
|
||||
login_hint.clone()
|
||||
} else if !login_hint.contains('.') {
|
||||
format!("{}.{}", login_hint.to_lowercase(), pds_hostname)
|
||||
} else {
|
||||
login_hint.to_lowercase()
|
||||
};
|
||||
tracing::info!(normalized = %normalized, "Normalized login_hint");
|
||||
|
||||
match sqlx::query!(
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
normalized
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
|
||||
let is_delegated = crate::delegation::is_delegated_account(&state.db, &user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_password = user.password_hash.is_some();
|
||||
tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check");
|
||||
|
||||
if is_delegated && !has_password {
|
||||
tracing::info!("Redirecting to delegation auth");
|
||||
return redirect_see_other(&format!(
|
||||
"/#/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&request_uri),
|
||||
url_encode(&user.did)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!(normalized = %normalized, "No user found for login_hint");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Error looking up user for login_hint");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No login_hint in request");
|
||||
}
|
||||
|
||||
if !force_new_account
|
||||
&& let Some(device_id) = extract_device_cookie(&headers)
|
||||
&& let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
|
||||
@@ -445,7 +494,8 @@ pub async fn authorize_post(
|
||||
SELECT id, did, email, password_hash, password_required, two_factor_enabled,
|
||||
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
|
||||
deactivated_at, takedown_ref,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
email_verified, discord_verified, telegram_verified, signal_verified,
|
||||
account_type::text as "account_type!"
|
||||
FROM users
|
||||
WHERE handle = $1 OR email = $1
|
||||
"#,
|
||||
@@ -481,6 +531,32 @@ pub async fn authorize_post(
|
||||
);
|
||||
}
|
||||
|
||||
if user.account_type == "delegated" {
|
||||
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(&user.did)
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "delegation",
|
||||
"delegated_did": user.did,
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
if !user.password_required {
|
||||
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
|
||||
.await
|
||||
@@ -1053,6 +1129,14 @@ pub struct ConsentResponse {
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_delegation: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_did: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegation_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1127,8 +1211,25 @@ pub async fn consent_get(
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
|
||||
|
||||
let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did {
|
||||
crate::delegation::get_delegation(&state.db, &did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
crate::delegation::scopes::intersect_scopes(requested_scope_str, &grant.granted_scopes)
|
||||
} else {
|
||||
requested_scope_str.to_string()
|
||||
};
|
||||
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let preferences =
|
||||
db::get_scope_preferences(&state.db, &did, &request_data.parameters.client_id)
|
||||
.await
|
||||
@@ -1182,6 +1283,31 @@ pub async fn consent_get(
|
||||
granted,
|
||||
});
|
||||
}
|
||||
let (is_delegation, controller_did, controller_handle, delegation_level) =
|
||||
if let Some(ref ctrl_did) = request_data.controller_did {
|
||||
let ctrl_handle =
|
||||
sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", ctrl_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let level = if let Some(ref grant) = delegation_grant {
|
||||
let preset = crate::delegation::SCOPE_PRESETS
|
||||
.iter()
|
||||
.find(|p| p.scopes == grant.granted_scopes);
|
||||
preset
|
||||
.map(|p| p.label.to_string())
|
||||
.unwrap_or_else(|| "Custom".to_string())
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
|
||||
(Some(true), Some(ctrl_did.clone()), ctrl_handle, Some(level))
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
Json(ConsentResponse {
|
||||
request_uri: query.request_uri.clone(),
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
@@ -1191,6 +1317,10 @@ pub async fn consent_get(
|
||||
scopes,
|
||||
show_consent,
|
||||
did,
|
||||
is_delegation,
|
||||
controller_did,
|
||||
controller_handle,
|
||||
delegation_level,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -1199,6 +1329,11 @@ pub async fn consent_post(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<ConsentSubmit>,
|
||||
) -> Response {
|
||||
tracing::info!(
|
||||
"consent_post: approved_scopes={:?}, remember={}",
|
||||
form.approved_scopes,
|
||||
form.remember
|
||||
);
|
||||
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
@@ -1246,12 +1381,28 @@ pub async fn consent_post(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let requested_scope_str = request_data
|
||||
let original_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
|
||||
|
||||
let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did {
|
||||
crate::delegation::get_delegation(&state.db, &did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
crate::delegation::scopes::intersect_scopes(original_scope_str, &grant.granted_scopes)
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
};
|
||||
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| {
|
||||
s.starts_with("repo:")
|
||||
|| s.starts_with("blob:")
|
||||
@@ -1640,6 +1791,10 @@ pub async fn check_user_has_passkeys(
|
||||
pub struct SecurityStatusResponse {
|
||||
pub has_passkeys: bool,
|
||||
pub has_totp: bool,
|
||||
pub has_password: bool,
|
||||
pub is_delegated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub did: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn check_user_security_status(
|
||||
@@ -1658,24 +1813,37 @@ pub async fn check_user_security_status(
|
||||
};
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT did FROM users WHERE handle = $1 OR email = $1",
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
normalized_identifier
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let (has_passkeys, has_totp) = match user {
|
||||
let (has_passkeys, has_totp, has_password, is_delegated, did): (
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
Option<String>,
|
||||
) = match user {
|
||||
Ok(Some(u)) => {
|
||||
let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await;
|
||||
let totp = crate::api::server::has_totp_enabled(&state, &u.did).await;
|
||||
(passkeys, totp)
|
||||
let has_pw = u.password_hash.is_some();
|
||||
let has_controllers = crate::delegation::is_delegated_account(&state.db, &u.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
(passkeys, totp, has_pw, has_controllers, Some(u.did))
|
||||
}
|
||||
_ => (false, false),
|
||||
_ => (false, false, false, false, None),
|
||||
};
|
||||
|
||||
Json(SecurityStatusResponse {
|
||||
has_passkeys,
|
||||
has_totp,
|
||||
has_password,
|
||||
is_delegated,
|
||||
did,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
use crate::delegation;
|
||||
use crate::oauth::db;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::util::extract_client_ip;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DelegationAuthSubmit {
|
||||
pub request_uri: String,
|
||||
pub delegated_did: Option<String>,
|
||||
pub controller_did: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DelegationAuthResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub needs_totp: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirect_uri: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn delegation_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<DelegationAuthSubmit>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::Login, &client_ip)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Too many login attempts. Please try again later.".to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let request = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Authorization request not found".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Server error".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let delegated_did = match form.delegated_did.as_ref().or(request.did.as_ref()) {
|
||||
Some(did) => did.clone(),
|
||||
None => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("No delegated account selected".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(_) = db::set_request_did(&state.db, &form.request_uri, &delegated_did).await {
|
||||
tracing::warn!("Failed to set delegated DID on authorization request");
|
||||
}
|
||||
|
||||
let grant =
|
||||
match delegation::get_delegation(&state.db, &delegated_did, &form.controller_did).await {
|
||||
Ok(Some(g)) => g,
|
||||
Ok(None) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("No delegation grant found for this controller".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Server error".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let controller = match sqlx::query!(
|
||||
r#"
|
||||
SELECT id, did, password_hash, deactivated_at, takedown_ref,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#,
|
||||
form.controller_did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Controller account not found".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Server error".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if controller.deactivated_at.is_some() {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Controller account is deactivated".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if controller.takedown_ref.is_some() {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Controller account has been taken down".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let password_valid = match &controller.password_hash {
|
||||
Some(hash) => match bcrypt::verify(&form.password, hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => false,
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !password_valid {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid password".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(_) = db::set_controller_did(&state.db, &form.request_uri, &form.controller_did).await
|
||||
{
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Failed to update authorization request".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let has_totp = crate::api::server::has_totp_enabled(&state, &form.controller_did).await;
|
||||
if has_totp {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: Some(true),
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/delegation-totp?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let ip = extract_client_ip(&headers);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&delegated_did,
|
||||
&form.controller_did,
|
||||
Some(&form.controller_did),
|
||||
delegation::DelegationActionType::TokenIssued,
|
||||
Some(serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
})),
|
||||
Some(&ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DelegationTotpSubmit {
|
||||
pub request_uri: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn delegation_totp_verify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<DelegationTotpSubmit>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &client_ip)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Too many verification attempts. Please try again later.".to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let request = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Authorization request not found".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Server error".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let controller_did = match &request.controller_did {
|
||||
Some(did) => did.clone(),
|
||||
None => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Controller not authenticated".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let delegated_did = match &request.did {
|
||||
Some(did) => did.clone(),
|
||||
None => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("No delegated account".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let grant = match delegation::get_delegation(&state.db, &delegated_did, &controller_did).await {
|
||||
Ok(Some(g)) => g,
|
||||
_ => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Delegation grant not found".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let totp_valid =
|
||||
crate::api::server::verify_totp_or_backup_for_user(&state, &controller_did, &form.code)
|
||||
.await;
|
||||
if !totp_valid {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: Some(true),
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid TOTP code".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let ip = extract_client_ip(&headers);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let _ = delegation::log_delegation_action(
|
||||
&state.db,
|
||||
&delegated_did,
|
||||
&controller_did,
|
||||
Some(&controller_did),
|
||||
delegation::DelegationActionType::TokenIssued,
|
||||
Some(serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
})),
|
||||
Some(&ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod authorize;
|
||||
pub mod delegation;
|
||||
pub mod metadata;
|
||||
pub mod par;
|
||||
pub mod token;
|
||||
|
||||
pub use authorize::*;
|
||||
pub use delegation::*;
|
||||
pub use metadata::*;
|
||||
pub use par::*;
|
||||
pub use token::*;
|
||||
|
||||
@@ -58,8 +58,10 @@ pub async fn pushed_authorization_request(
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
|
||||
} else if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||
serde_urlencoded::from_bytes(&body)
|
||||
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?
|
||||
let parsed: ParRequest = serde_urlencoded::from_bytes(&body)
|
||||
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?;
|
||||
tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)");
|
||||
parsed
|
||||
} else {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"Content-Type must be application/json or application/x-www-form-urlencoded"
|
||||
@@ -128,6 +130,7 @@ pub async fn pushed_authorization_request(
|
||||
did: None,
|
||||
device_id: None,
|
||||
code: None,
|
||||
controller_did: None,
|
||||
};
|
||||
db::create_authorization_request(&state.db, &request_id.0, &request_data).await?;
|
||||
tokio::spawn({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::helpers::{create_access_token, verify_pkce};
|
||||
use super::helpers::{create_access_token_with_delegation, verify_pkce};
|
||||
use super::types::{TokenRequest, TokenResponse};
|
||||
use crate::config::AuthConfig;
|
||||
use crate::delegation;
|
||||
use crate::oauth::{
|
||||
ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
|
||||
client::{ClientMetadataCache, verify_client_auth},
|
||||
@@ -106,11 +107,30 @@ pub async fn handle_authorization_code_grant(
|
||||
let token_id = TokenId::generate();
|
||||
let refresh_token = RefreshToken::generate();
|
||||
let now = Utc::now();
|
||||
let access_token = create_access_token(
|
||||
|
||||
let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did {
|
||||
let grant = delegation::get_delegation(&state.db, &did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
|
||||
let requested = auth_request
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
|
||||
(Some(intersected), Some(controller.clone()))
|
||||
} else {
|
||||
(auth_request.parameters.scope.clone(), None)
|
||||
};
|
||||
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_id.0,
|
||||
&did,
|
||||
dpop_jkt.as_deref(),
|
||||
auth_request.parameters.scope.as_deref(),
|
||||
final_scope.as_deref(),
|
||||
controller_did.as_deref(),
|
||||
)?;
|
||||
let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None);
|
||||
let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) {
|
||||
@@ -131,7 +151,8 @@ pub async fn handle_authorization_code_grant(
|
||||
details: None,
|
||||
code: None,
|
||||
current_refresh_token: Some(refresh_token.0.clone()),
|
||||
scope: auth_request.parameters.scope.clone(),
|
||||
scope: final_scope.clone(),
|
||||
controller_did: controller_did.clone(),
|
||||
};
|
||||
db::create_token(&state.db, &token_data).await?;
|
||||
tokio::spawn({
|
||||
@@ -154,7 +175,7 @@ pub async fn handle_authorization_code_grant(
|
||||
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
|
||||
refresh_token: Some(refresh_token.0),
|
||||
scope: auth_request.parameters.scope,
|
||||
scope: final_scope,
|
||||
sub: Some(did),
|
||||
}),
|
||||
))
|
||||
@@ -183,11 +204,12 @@ pub async fn handle_refresh_token_grant(
|
||||
"Refresh token reuse within grace period, returning existing tokens"
|
||||
);
|
||||
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
|
||||
let access_token = create_access_token(
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_data.token_id,
|
||||
&token_data.did,
|
||||
dpop_jkt,
|
||||
token_data.scope.as_deref(),
|
||||
token_data.controller_did.as_deref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
let config = AuthConfig::get();
|
||||
@@ -282,11 +304,12 @@ pub async fn handle_refresh_token_grant(
|
||||
new_expires_at = %new_expires_at,
|
||||
"Refresh token rotated successfully"
|
||||
);
|
||||
let access_token = create_access_token(
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&new_token_id.0,
|
||||
&token_data.did,
|
||||
dpop_jkt.as_deref(),
|
||||
token_data.scope.as_deref(),
|
||||
token_data.controller_did.as_deref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
let config = AuthConfig::get();
|
||||
|
||||
@@ -37,6 +37,16 @@ pub fn create_access_token(
|
||||
sub: &str,
|
||||
dpop_jkt: Option<&str>,
|
||||
scope: Option<&str>,
|
||||
) -> Result<String, OAuthError> {
|
||||
create_access_token_with_delegation(token_id, sub, dpop_jkt, scope, None)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_delegation(
|
||||
token_id: &str,
|
||||
sub: &str,
|
||||
dpop_jkt: Option<&str>,
|
||||
scope: Option<&str>,
|
||||
controller_did: Option<&str>,
|
||||
) -> Result<String, OAuthError> {
|
||||
use serde_json::json;
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -56,6 +66,9 @@ pub fn create_access_token(
|
||||
if let Some(jkt) = dpop_jkt {
|
||||
payload["cnf"] = json!({ "jkt": jkt });
|
||||
}
|
||||
if let Some(controller) = controller_did {
|
||||
payload["act"] = json!({ "sub": controller });
|
||||
}
|
||||
let header = json!({
|
||||
"alg": "HS256",
|
||||
"typ": "at+jwt"
|
||||
|
||||
@@ -40,8 +40,8 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
|
||||
scope: "atproto",
|
||||
category: ScopeCategory::Core,
|
||||
required: true,
|
||||
description: "Use AT Protocol OAuth (required for all sessions)",
|
||||
display_name: "AT Protocol",
|
||||
description: "Full access to read, write, and manage this account",
|
||||
display_name: "Full Account Access",
|
||||
},
|
||||
ScopeDefinition {
|
||||
scope: "transition:generic",
|
||||
@@ -92,6 +92,20 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
|
||||
description: "Upload images, videos, and other media files",
|
||||
display_name: "Upload Media",
|
||||
},
|
||||
ScopeDefinition {
|
||||
scope: "repo:*",
|
||||
category: ScopeCategory::Repo,
|
||||
required: false,
|
||||
description: "Full read and write access to all repository records",
|
||||
display_name: "Full Repository Access",
|
||||
},
|
||||
ScopeDefinition {
|
||||
scope: "account:*?action=manage",
|
||||
category: ScopeCategory::Account,
|
||||
required: false,
|
||||
description: "Manage account settings and preferences",
|
||||
display_name: "Manage Account",
|
||||
},
|
||||
];
|
||||
|
||||
definitions.into_iter().map(|d| (d.scope, d)).collect()
|
||||
|
||||
@@ -107,6 +107,7 @@ pub struct RequestData {
|
||||
pub did: Option<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -132,6 +133,7 @@ pub struct TokenData {
|
||||
pub code: Option<String>,
|
||||
pub current_refresh_token: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct OAuthTokenInfo {
|
||||
pub client_id: String,
|
||||
pub scope: Option<String>,
|
||||
pub dpop_jkt: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
}
|
||||
|
||||
pub struct VerifyResult {
|
||||
@@ -148,12 +149,18 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let controller_did = payload
|
||||
.get("act")
|
||||
.and_then(|a| a.get("sub"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
Ok(OAuthTokenInfo {
|
||||
did,
|
||||
token_id,
|
||||
client_id,
|
||||
scope,
|
||||
dpop_jkt,
|
||||
controller_did,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -1,3 +1,4 @@
|
||||
use axum::http::HeaderMap;
|
||||
use rand::Rng;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
@@ -72,6 +73,21 @@ pub async fn get_user_by_identifier(
|
||||
.ok_or(DbLookupError::NotFound)
|
||||
}
|
||||
|
||||
pub fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next()
|
||||
{
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str()
|
||||
{
|
||||
return value.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -382,6 +382,32 @@ pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_valid_did(did: &str) -> bool {
|
||||
if !did.starts_with("did:") {
|
||||
return false;
|
||||
}
|
||||
let parts: Vec<&str> = did.splitn(3, ':').collect();
|
||||
if parts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let method = parts[1];
|
||||
if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
let id = parts[2];
|
||||
!id.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate_did(did: &str) -> Result<(), ValidationError> {
|
||||
if !is_valid_did(did) {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "did".to_string(),
|
||||
message: "Invalid DID format".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> {
|
||||
if collection.is_empty() {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
@@ -604,4 +630,19 @@ mod tests {
|
||||
assert!(validate_collection_nsid("a.b").is_err());
|
||||
assert!(validate_collection_nsid("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_did() {
|
||||
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
|
||||
assert!(is_valid_did("did:web:example.com"));
|
||||
assert!(is_valid_did(
|
||||
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
||||
));
|
||||
assert!(!is_valid_did(""));
|
||||
assert!(!is_valid_did("plc:1234567890abcdefghijk"));
|
||||
assert!(!is_valid_did("did:"));
|
||||
assert!(!is_valid_did("did:plc:"));
|
||||
assert!(!is_valid_did("did::something"));
|
||||
assert!(!is_valid_did("DID:plc:test"));
|
||||
}
|
||||
}
|
||||
|
||||
+193
-12
@@ -3,7 +3,7 @@ mod common;
|
||||
mod helpers;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::Utc;
|
||||
use common::{base_url, client};
|
||||
use common::{base_url, client, create_account_and_login};
|
||||
use helpers::verify_new_account;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
@@ -439,18 +439,16 @@ async fn test_replay_attacks() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rt_replay.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Refresh token replay should fail"
|
||||
StatusCode::OK,
|
||||
"Refresh token reuse within grace period should return existing tokens"
|
||||
);
|
||||
let body: Value = rt_replay.json().await.unwrap();
|
||||
assert!(
|
||||
body["error_description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.contains("reuse")
|
||||
let grace_body: Value = rt_replay.json().await.unwrap();
|
||||
assert_eq!(
|
||||
grace_body["refresh_token"].as_str().unwrap(),
|
||||
new_rt,
|
||||
"Grace period response should return the current refresh token"
|
||||
);
|
||||
let family_revoked = http_client
|
||||
let second_refresh: Value = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
@@ -459,11 +457,53 @@ async fn test_replay_attacks() {
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
second_refresh["access_token"].is_string(),
|
||||
"Second refresh with new token should succeed"
|
||||
);
|
||||
let newest_rt = second_refresh["refresh_token"].as_str().unwrap();
|
||||
let replay_after_rotation = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", &stolen_rt),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replay_after_rotation.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Replay of original token after another rotation should fail"
|
||||
);
|
||||
let body: Value = replay_after_rotation.json().await.unwrap();
|
||||
assert!(
|
||||
body["error_description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.contains("reuse"),
|
||||
"Error should indicate token reuse"
|
||||
);
|
||||
let family_revoked = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", newest_rt),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
family_revoked.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Token family should be revoked"
|
||||
"Token family should be revoked after replay detection"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1065,3 +1105,144 @@ fn test_dpop_http_method_case() {
|
||||
"HTTP method should be case-insensitive"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delegation_viewer_scope_cannot_write() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
|
||||
let delegated_handle = format!("deleg-{}", ts);
|
||||
let delegated_res = http_client
|
||||
.post(format!("{}/xrpc/com.tranquil.delegation.createDelegatedAccount", url))
|
||||
.bearer_auth(controller_jwt)
|
||||
.json(&json!({
|
||||
"handle": delegated_handle,
|
||||
"controllerScopes": ""
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
if delegated_res.status() != StatusCode::OK {
|
||||
let error_body = delegated_res.text().await.unwrap();
|
||||
panic!("Failed to create delegated account: {}", error_body);
|
||||
}
|
||||
let delegated_account: Value = delegated_res.json().await.unwrap();
|
||||
let delegated_did = delegated_account["did"].as_str().unwrap();
|
||||
|
||||
let redirect_uri = "https://example.com/deleg-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let par_body: Value = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", "atproto"),
|
||||
("login_hint", delegated_did),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/delegation/auth", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"delegated_did": delegated_did,
|
||||
"controller_did": controller_did,
|
||||
"password": "Testpass123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
if auth_res.status() != StatusCode::OK {
|
||||
let error_body = auth_res.text().await.unwrap();
|
||||
panic!("Delegation auth failed: {}", error_body);
|
||||
}
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
assert!(auth_body["success"].as_bool().unwrap_or(false), "Delegation auth should succeed: {:?}", auth_body);
|
||||
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": ["atproto"],
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
if consent_res.status() != StatusCode::OK {
|
||||
let error_body = consent_res.text().await.unwrap();
|
||||
panic!("Consent failed: {}", error_body);
|
||||
}
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
let location = consent_body["redirect_uri"].as_str().unwrap();
|
||||
|
||||
let code = location
|
||||
.split("code=")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('&')
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let token_res = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(token_res.status(), StatusCode::OK);
|
||||
let tokens: Value = token_res.json().await.unwrap();
|
||||
let access_token = tokens["access_token"].as_str().unwrap();
|
||||
|
||||
let create_post_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
||||
.bearer_auth(access_token)
|
||||
.json(&json!({
|
||||
"repo": delegated_did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Test post from viewer",
|
||||
"createdAt": Utc::now().to_rfc3339()
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_post_res.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"Viewer scope delegation should not be able to create posts"
|
||||
);
|
||||
let error_body: Value = create_post_res.json().await.unwrap();
|
||||
assert_eq!(
|
||||
error_body["error"].as_str().unwrap(),
|
||||
"InsufficientScope",
|
||||
"Error should be InsufficientScope"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user