mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-16 22:36:07 +00:00
feat: cross-pds delegation
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n d.controller_did,\n u.handle as \"handle?\",\n d.granted_scopes,\n d.granted_at,\n true as \"is_active!\",\n u.did IS NOT NULL as \"is_local!\"\n FROM account_delegations d\n LEFT 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.did IS NULL OR (u.deactivated_at IS NULL AND u.takedown_ref IS NULL))\n ORDER BY d.granted_at DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "controller_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"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_local!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n d.controller_did,\n u.handle as \"handle?\",\n d.granted_scopes,\n d.granted_at,\n CASE WHEN u.did IS NOT NULL\n THEN u.deactivated_at IS NULL AND u.takedown_ref IS NULL\n ELSE true\n END as \"is_active!\",\n u.did IS NOT NULL as \"is_local!\"\n FROM account_delegations d\n LEFT 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": "controller_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"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_local!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT COUNT(*) as \"count!\"\n FROM account_delegations d\n LEFT 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.did IS NULL OR (u.deactivated_at IS NULL AND u.takedown_ref IS NULL))\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96"
|
||||
}
|
||||
Generated
+20
-20
@@ -6094,7 +6094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6142,7 +6142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -6165,7 +6165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6179,7 +6179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -6202,7 +6202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -6218,7 +6218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6235,7 +6235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6251,7 +6251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -6262,7 +6262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
@@ -6280,7 +6280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6303,7 +6303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -6336,7 +6336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -6424,7 +6424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -6436,7 +6436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -6461,7 +6461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -6477,7 +6477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -6514,7 +6514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ COPY frontend/ ./
|
||||
RUN deno task build
|
||||
|
||||
FROM rust:1.92-alpine AS builder
|
||||
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang
|
||||
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
||||
WORKDIR /app
|
||||
ARG SLIM="false"
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::identity::provision::{create_plc_did, init_genesis_repo};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::delegation::{
|
||||
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
|
||||
verify_can_be_controller, verify_can_control_accounts,
|
||||
verify_can_control_accounts,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
@@ -14,28 +14,10 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jacquard_common::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: Did,
|
||||
pub handle: Handle,
|
||||
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: Auth<Active>,
|
||||
@@ -54,19 +36,23 @@ pub async fn list_controllers(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ListControllersResponse {
|
||||
controllers: controllers
|
||||
.into_iter()
|
||||
.map(|c| ControllerInfo {
|
||||
did: c.did,
|
||||
handle: c.handle,
|
||||
granted_scopes: c.granted_scopes.into_string(),
|
||||
granted_at: c.granted_at,
|
||||
is_active: c.is_active,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response())
|
||||
let resolve_futures = controllers.into_iter().map(|mut c| {
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
async move {
|
||||
if c.handle.is_none() {
|
||||
c.handle = did_resolver
|
||||
.resolve_did_document(c.did.as_str())
|
||||
.await
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
|
||||
.map(|h| h.into());
|
||||
}
|
||||
c
|
||||
}
|
||||
});
|
||||
|
||||
let controllers = futures::future::join_all(resolve_futures).await;
|
||||
|
||||
Ok(Json(serde_json::json!({ "controllers": controllers })).into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -80,16 +66,39 @@ pub async fn add_controller(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<AddControllerInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let controller_exists = state
|
||||
.user_repo
|
||||
.get_by_did(&input.controller_did)
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
.ok_or(ApiError::ControllerNotFound)?;
|
||||
|
||||
if !controller_exists {
|
||||
return Ok(ApiError::ControllerNotFound.into_response());
|
||||
if !resolved.is_local {
|
||||
if let Some(ref pds_url) = resolved.pds_url {
|
||||
if !pds_url.starts_with("https://") {
|
||||
return Ok(
|
||||
ApiError::InvalidDelegation("Controller PDS must use HTTPS".into())
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
match state
|
||||
.cross_pds_oauth
|
||||
.check_remote_is_delegated(pds_url, input.controller_did.as_str())
|
||||
.await
|
||||
{
|
||||
Some(true) => {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
"Cannot add a delegated account from another PDS as a controller".into(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Some(false) => {}
|
||||
None => {
|
||||
warn!(
|
||||
controller = %input.controller_did,
|
||||
pds = %pds_url,
|
||||
"Could not verify remote controller delegation status"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let can_add = match verify_can_add_controllers(&state, &auth).await {
|
||||
@@ -97,16 +106,19 @@ pub async fn add_controller(
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
if resolved.is_local {
|
||||
if state.delegation_repo.is_delegated_account(&input.controller_did).await.unwrap_or(false) {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
).into_response());
|
||||
}
|
||||
}
|
||||
|
||||
match state
|
||||
.delegation_repo
|
||||
.create_delegation(
|
||||
can_add.did(),
|
||||
can_be_controller.did(),
|
||||
&input.controller_did,
|
||||
&input.granted_scopes,
|
||||
can_add.did(),
|
||||
)
|
||||
@@ -118,10 +130,11 @@ pub async fn add_controller(
|
||||
.log_delegation_action(
|
||||
can_add.did(),
|
||||
can_add.did(),
|
||||
Some(can_be_controller.did()),
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(serde_json::json!({
|
||||
"granted_scopes": input.granted_scopes.as_str()
|
||||
"granted_scopes": input.granted_scopes.as_str(),
|
||||
"is_local": resolved.is_local
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
@@ -256,20 +269,6 @@ pub async fn update_controller_scopes(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
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: Auth<Active>,
|
||||
@@ -289,18 +288,7 @@ pub async fn list_controlled_accounts(
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ListControlledAccountsResponse {
|
||||
accounts: accounts
|
||||
.into_iter()
|
||||
.map(|a| DelegatedAccountInfo {
|
||||
did: a.did,
|
||||
handle: a.handle,
|
||||
granted_scopes: a.granted_scopes.into_string(),
|
||||
granted_at: a.granted_at,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response())
|
||||
Ok(Json(serde_json::json!({ "accounts": accounts })).into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -315,24 +303,6 @@ fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: String,
|
||||
pub delegated_did: Did,
|
||||
pub actor_did: Did,
|
||||
pub controller_did: Option<Did>,
|
||||
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: Auth<Active>,
|
||||
@@ -361,50 +331,11 @@ pub async fn get_audit_log(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(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>,
|
||||
Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_scope_presets() -> Response {
|
||||
Json(GetScopePresetsResponse {
|
||||
presets: SCOPE_PRESETS
|
||||
.iter()
|
||||
.map(|p| ScopePresetInfo {
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
scopes: p.scopes,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response()
|
||||
Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -434,29 +365,11 @@ pub async fn create_delegated_account(
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
|
||||
Err(e) => {
|
||||
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
|
||||
}
|
||||
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
|
||||
}
|
||||
} else {
|
||||
input.handle.to_lowercase()
|
||||
};
|
||||
|
||||
let email = input
|
||||
@@ -483,96 +396,15 @@ pub async fn create_delegated_account(
|
||||
None
|
||||
};
|
||||
|
||||
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 Ok(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&signing_key));
|
||||
|
||||
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to create PLC operation".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let plc_client = tranquil_pds::plc::PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
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 Ok(ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let did: Did = genesis_result
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
|
||||
let plc = create_plc_did(&state, &handle).await.map_err(|e| {
|
||||
tracing::error!("PLC DID creation failed: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
let did = plc.did;
|
||||
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Error encrypting signing key: {:?}", e);
|
||||
return Ok(ApiError::InternalError(None).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 Ok(ApiError::InternalError(None).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 Ok(ApiError::InternalError(None).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 Ok(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
};
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
|
||||
|
||||
let create_input = tranquil_db_traits::CreateDelegatedAccountInput {
|
||||
handle: handle.clone(),
|
||||
@@ -580,11 +412,11 @@ pub async fn create_delegated_account(
|
||||
did: did.clone(),
|
||||
controller_did: can_control.did().clone(),
|
||||
controller_scopes: input.controller_scopes.as_str().to_string(),
|
||||
encrypted_key_bytes,
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: commit_cid.to_string(),
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
genesis_block_cids,
|
||||
commit_cid: repo.commit_cid.to_string(),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
};
|
||||
|
||||
@@ -666,3 +498,40 @@ pub async fn create_delegated_account(
|
||||
|
||||
Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveControllerParams {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
pub async fn resolve_controller(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ResolveControllerParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let identifier = params.identifier.trim().trim_start_matches('@');
|
||||
|
||||
let did: Did = if identifier.starts_with("did:") {
|
||||
identifier.parse().map_err(|_| ApiError::ControllerNotFound)?
|
||||
} else {
|
||||
let local_handle: Option<Handle> = identifier.parse().ok();
|
||||
let local_user = match local_handle {
|
||||
Some(ref h) => state.user_repo.get_by_handle(h).await.ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
match local_user {
|
||||
Some(user) => user.did,
|
||||
None => tranquil_pds::handle::resolve_handle(identifier)
|
||||
.await
|
||||
.map_err(|_| ApiError::ControllerNotFound)?
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?,
|
||||
}
|
||||
};
|
||||
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
|
||||
.await
|
||||
.ok_or(ApiError::ControllerNotFound)?;
|
||||
|
||||
Ok(Json(resolved).into_response())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use super::did::verify_did_web;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
|
||||
use tranquil_pds::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
@@ -14,13 +12,10 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::{SecretKey, ecdsa::SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -141,31 +136,9 @@ pub async fn create_account(
|
||||
}
|
||||
|
||||
let cfg = tranquil_config::get();
|
||||
let available_domains = cfg.server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return ApiError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
}
|
||||
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
let email: Option<String> = input
|
||||
.email
|
||||
@@ -221,12 +194,6 @@ pub async fn create_account(
|
||||
})
|
||||
};
|
||||
let hostname = &cfg.server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let handle = match matched_domain {
|
||||
Some(domain) => format!("{}.{}", validated_short_handle, domain),
|
||||
None if input.handle.contains('.') => validated_short_handle.clone(),
|
||||
None => format!("{}.{}", validated_short_handle, &available_domains[0]),
|
||||
};
|
||||
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
|
||||
if let Some(signing_key_did) = &input.signing_key {
|
||||
match state
|
||||
@@ -308,76 +275,17 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to create PLC operation".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
match super::provision::submit_plc_genesis(&state, &signing_key, &handle).await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
Ok(did) => did,
|
||||
Err(e) => return e.into_response(),
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
}
|
||||
} else {
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to create PLC operation".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
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 ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
match super::provision::submit_plc_genesis(&state, &signing_key, &handle).await {
|
||||
Ok(did) => did,
|
||||
Err(e) => return e.into_response(),
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -453,7 +361,7 @@ pub async fn create_account(
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
@@ -590,45 +498,23 @@ pub async fn create_account(
|
||||
None
|
||||
};
|
||||
|
||||
let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
error!("Error encrypting user key: {:?}", e);
|
||||
return ApiError::InternalError(None).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 ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did_for_commit: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error saving genesis commit: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let repo = match super::provision::init_genesis_repo(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&signing_key,
|
||||
&secret_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
let rev_str = rev.as_ref().to_string();
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
let commit_cid_str = repo.commit_cid.to_string();
|
||||
let rev_str = repo.repo_rev.clone();
|
||||
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
Some(json!({
|
||||
@@ -665,12 +551,12 @@ pub async fn create_account(
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_lowercase()),
|
||||
deactivated_at,
|
||||
encrypted_key_bytes,
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: commit_cid_str.clone(),
|
||||
repo_rev: rev_str.clone(),
|
||||
genesis_block_cids,
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
@@ -718,8 +604,8 @@ pub async fn create_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&commit_cid,
|
||||
&mst_root,
|
||||
&repo.commit_cid,
|
||||
&repo.mst_root_cid,
|
||||
&rev_str,
|
||||
)
|
||||
.await
|
||||
@@ -730,7 +616,7 @@ pub async fn create_account(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&commit_cid_str,
|
||||
Some(rev.as_ref()),
|
||||
Some(&rev_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -821,7 +707,7 @@ pub async fn create_account(
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
|
||||
@@ -813,7 +813,7 @@ pub async fn update_plc_handle(
|
||||
};
|
||||
let key_bytes = tranquil_pds::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?;
|
||||
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?;
|
||||
let plc_client = tranquil_pds::plc::PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
let plc_client = state.plc_client();
|
||||
let last_op = plc_client.get_last_op(did).await?;
|
||||
let new_also_known_as = vec![format!("at://{}", new_handle)];
|
||||
let update_op =
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod account;
|
||||
pub mod did;
|
||||
pub mod handle;
|
||||
pub mod plc;
|
||||
pub mod provision;
|
||||
|
||||
pub use account::create_account;
|
||||
pub use did::{
|
||||
|
||||
@@ -2,7 +2,7 @@ use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
||||
use tranquil_pds::plc::{PlcClient, PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
||||
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
||||
use tranquil_pds::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -97,7 +97,7 @@ pub async fn sign_plc_operation(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
let plc_client = state.plc_client();
|
||||
let did_clone = did.clone();
|
||||
let last_op = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client.get_last_op(&did_clone).await
|
||||
|
||||
@@ -2,7 +2,7 @@ use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::api::{ApiError, EmptyResponse};
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
||||
use tranquil_pds::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
|
||||
use tranquil_pds::plc::{signing_key_to_did_key, validate_plc_operation};
|
||||
use tranquil_pds::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -120,7 +120,7 @@ pub async fn submit_plc_operation(
|
||||
));
|
||||
}
|
||||
}
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
let plc_client = state.plc_client();
|
||||
let operation_clone = input.operation.clone();
|
||||
let did_clone = did.clone();
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct PlcDidResult {
|
||||
pub did: Did,
|
||||
pub signing_key_bytes: Vec<u8>,
|
||||
pub signing_key: SigningKey,
|
||||
}
|
||||
|
||||
pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResult, ApiError> {
|
||||
use k256::SecretKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
let secret_key = SecretKey::random(&mut OsRng);
|
||||
let secret_key_bytes = secret_key.to_bytes().to_vec();
|
||||
let signing_key = SigningKey::from_slice(&secret_key_bytes).map_err(|e| {
|
||||
tracing::error!("Error creating signing key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let did_str = submit_plc_genesis(state, &signing_key, handle).await?;
|
||||
let did: Did = did_str
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
|
||||
|
||||
Ok(PlcDidResult {
|
||||
did,
|
||||
signing_key_bytes: secret_key_bytes,
|
||||
signing_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_plc_genesis(
|
||||
state: &AppState,
|
||||
signing_key: &SigningKey,
|
||||
handle: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key));
|
||||
|
||||
let genesis_result =
|
||||
tranquil_pds::plc::create_genesis_operation(signing_key, &rotation_key, handle, &pds_endpoint)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating PLC genesis operation: {:?}", e);
|
||||
ApiError::InternalError(Some("Failed to create PLC operation".into()))
|
||||
})?;
|
||||
|
||||
state
|
||||
.plc_client()
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
ApiError::UpstreamErrorMsg(format!("Failed to register DID with PLC directory: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!(did = %genesis_result.did, "Registered DID with PLC directory");
|
||||
Ok(genesis_result.did)
|
||||
}
|
||||
|
||||
pub struct GenesisRepo {
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub commit_cid: cid::Cid,
|
||||
pub mst_root_cid: cid::Cid,
|
||||
pub repo_rev: String,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub async fn init_genesis_repo(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
signing_key: &SigningKey,
|
||||
signing_key_bytes: &[u8],
|
||||
) -> Result<GenesisRepo, ApiError> {
|
||||
let encrypted_key_bytes = tranquil_pds::config::encrypt_key(signing_key_bytes).map_err(|e| {
|
||||
tracing::error!("Error encrypting signing key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let mst = Mst::new(Arc::new(state.block_store.clone()));
|
||||
let mst_root = mst.persist().await.map_err(|e| {
|
||||
tracing::error!("Error persisting MST: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, rev.as_ref(), None, signing_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating genesis commit: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let commit_cid: cid::Cid = state.block_store.put(&commit_bytes).await.map_err(|e| {
|
||||
tracing::error!("Error saving genesis commit: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
Ok(GenesisRepo {
|
||||
encrypted_key_bytes,
|
||||
commit_cid,
|
||||
mst_root_cid: mst_root,
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
@@ -228,6 +228,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route("/_delegation.getAuditLog", get(delegation::get_audit_log))
|
||||
.route("/_delegation.getScopePresets", get(delegation::get_scope_presets))
|
||||
.route("/_delegation.createDelegatedAccount", post(delegation::create_delegated_account))
|
||||
.route("/_delegation.resolveController", get(delegation::resolve_controller))
|
||||
.route("/_backup.listBackups", get(backup::list_backups))
|
||||
.route("/_backup.getBackup", get(backup::get_backup))
|
||||
.route("/_backup.createBackup", post(backup::create_backup))
|
||||
|
||||
@@ -114,31 +114,9 @@ pub async fn create_passkey_account(
|
||||
|
||||
let cfg = tranquil_config::get();
|
||||
let hostname = &cfg.server.hostname;
|
||||
let available_domains = cfg.server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(None).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
}
|
||||
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
|
||||
let email = input
|
||||
@@ -558,7 +536,7 @@ pub async fn create_passkey_account(
|
||||
refresh_expires_at: refresh_expires,
|
||||
login_type: tranquil_db::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
scope: Some("transition:generic".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
|
||||
@@ -708,7 +708,7 @@ pub async fn confirm_signup(
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct DelegationGrant {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
@@ -28,12 +29,14 @@ pub struct DelegatedAccountInfo {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub handle: Option<Handle>,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
pub is_local: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -48,6 +51,7 @@ pub enum DelegationActionType {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: Uuid,
|
||||
pub delegated_did: Did,
|
||||
@@ -55,7 +59,9 @@ pub struct AuditLogEntry {
|
||||
pub controller_did: Option<Did>,
|
||||
pub action_type: DelegationActionType,
|
||||
pub action_details: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing)]
|
||||
pub ip_address: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -102,15 +108,8 @@ pub trait DelegationRepository: Send + Sync {
|
||||
controller_did: &Did,
|
||||
) -> Result<Vec<DelegatedAccountInfo>, DbError>;
|
||||
|
||||
async fn get_active_controllers_for_account(
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
) -> Result<Vec<ControllerInfo>, DbError>;
|
||||
|
||||
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError>;
|
||||
|
||||
async fn has_any_controllers(&self, did: &Did) -> Result<bool, DbError>;
|
||||
|
||||
async fn controls_any_accounts(&self, did: &Did) -> Result<bool, DbError>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -132,12 +131,5 @@ pub trait DelegationRepository: Send + Sync {
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>, DbError>;
|
||||
|
||||
async fn get_audit_log_by_controller(
|
||||
&self,
|
||||
controller_did: &Did,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>, DbError>;
|
||||
|
||||
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError>;
|
||||
}
|
||||
|
||||
@@ -185,13 +185,17 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
u.did,
|
||||
u.handle,
|
||||
d.controller_did,
|
||||
u.handle as "handle?",
|
||||
d.granted_scopes,
|
||||
d.granted_at,
|
||||
(u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!"
|
||||
CASE WHEN u.did IS NOT NULL
|
||||
THEN u.deactivated_at IS NULL AND u.takedown_ref IS NULL
|
||||
ELSE true
|
||||
END as "is_active!",
|
||||
u.did IS NOT NULL as "is_local!"
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.controller_did
|
||||
LEFT 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
|
||||
"#,
|
||||
@@ -204,11 +208,12 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
did: r.controller_did.into(),
|
||||
handle: r.handle.map(Into::into),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
is_local: r.is_local,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -249,54 +254,15 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_active_controllers_for_account(
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
) -> Result<Vec<ControllerInfo>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
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.as_str()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT COUNT(*) as "count!"
|
||||
FROM account_delegations d
|
||||
JOIN users u ON u.did = d.controller_did
|
||||
LEFT 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
|
||||
AND (u.did IS NULL OR (u.deactivated_at IS NULL AND u.takedown_ref IS NULL))
|
||||
"#,
|
||||
delegated_did.as_str()
|
||||
)
|
||||
@@ -307,21 +273,6 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn has_any_controllers(&self, did: &Did) -> Result<bool, DbError> {
|
||||
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.as_str()
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn controls_any_accounts(&self, did: &Did) -> Result<bool, DbError> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
@@ -418,53 +369,6 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_audit_log_by_controller(
|
||||
&self,
|
||||
controller_did: &Did,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AuditLogEntry>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
delegated_did,
|
||||
actor_did,
|
||||
controller_did,
|
||||
action_type as "action_type: PgDelegationActionType",
|
||||
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.as_str(),
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditLogEntry {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
actor_did: r.actor_did.into(),
|
||||
controller_did: r.controller_did.map(Into::into),
|
||||
action_type: r.action_type.into(),
|
||||
action_details: r.action_details,
|
||||
ip_address: r.ip_address,
|
||||
user_agent: r.user_agent,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#,
|
||||
|
||||
@@ -1,37 +1,179 @@
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::delegation::DelegationActionType;
|
||||
use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls};
|
||||
use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::util::extract_client_ip;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_pds::oauth::RequestData;
|
||||
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
|
||||
use tranquil_types::{Did, RequestId};
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn parse_did(s: &str, label: &str) -> Result<Did, Response> {
|
||||
s.parse()
|
||||
.map_err(|_| DelegationAuthResponse::err(format!("Invalid {} DID", label)))
|
||||
}
|
||||
|
||||
async fn get_auth_request(state: &AppState, request_uri: &str) -> Result<RequestData, Response> {
|
||||
let request_id = RequestId::from(request_uri.to_string());
|
||||
match state
|
||||
.oauth_repo
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) => Ok(r),
|
||||
Ok(None) => Err(DelegationAuthResponse::err(
|
||||
"Authorization request not found",
|
||||
)),
|
||||
Err(_) => Err(DelegationAuthResponse::err("Server error")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_delegation_grant(
|
||||
state: &AppState,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
) -> Result<tranquil_db_traits::DelegationGrant, Response> {
|
||||
match state
|
||||
.delegation_repo
|
||||
.get_delegation(delegated_did, controller_did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(g)) => Ok(g),
|
||||
Ok(None) => Err(DelegationAuthResponse::err(
|
||||
"No delegation grant found for this controller",
|
||||
)),
|
||||
Err(_) => Err(DelegationAuthResponse::err("Server error")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_delegation_auth(
|
||||
state: &AppState,
|
||||
request_uri: &str,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
details: serde_json::Value,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Response {
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
delegated_did,
|
||||
controller_did,
|
||||
Some(controller_did),
|
||||
DelegationActionType::TokenIssued,
|
||||
Some(details),
|
||||
ip,
|
||||
user_agent,
|
||||
)
|
||||
.await;
|
||||
consent_redirect(request_uri)
|
||||
}
|
||||
|
||||
async fn bind_delegation_to_request(
|
||||
state: &AppState,
|
||||
request_uri: &str,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
) -> Result<(), Response> {
|
||||
let request_id = RequestId::from(request_uri.to_string());
|
||||
state
|
||||
.oauth_repo
|
||||
.set_request_did(&request_id, delegated_did)
|
||||
.await
|
||||
.map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?;
|
||||
state
|
||||
.oauth_repo
|
||||
.set_controller_did(&request_id, controller_did)
|
||||
.await
|
||||
.map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn consent_url(request_uri: &str) -> String {
|
||||
format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(request_uri)
|
||||
)
|
||||
}
|
||||
|
||||
fn consent_redirect(request_uri: &str) -> Response {
|
||||
DelegationAuthResponse::redirect(consent_url(request_uri))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DelegationAuthSubmit {
|
||||
pub request_uri: String,
|
||||
pub delegated_did: Option<String>,
|
||||
pub controller_did: String,
|
||||
pub password: PlainPassword,
|
||||
pub password: Option<PlainPassword>,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
pub auth_method: Option<String>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
enum DelegationAuthResponse {
|
||||
Redirect(String),
|
||||
NeedsTotp(String),
|
||||
Error(String),
|
||||
TotpError(String),
|
||||
}
|
||||
|
||||
impl DelegationAuthResponse {
|
||||
fn err(msg: impl Into<String>) -> Response {
|
||||
Self::Error(msg.into()).into_response()
|
||||
}
|
||||
|
||||
fn redirect(uri: impl Into<String>) -> Response {
|
||||
Self::Redirect(uri.into()).into_response()
|
||||
}
|
||||
|
||||
fn needs_totp(uri: impl Into<String>) -> Response {
|
||||
Self::NeedsTotp(uri.into()).into_response()
|
||||
}
|
||||
|
||||
fn totp_error(msg: impl Into<String>) -> Response {
|
||||
Self::TotpError(msg.into()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for DelegationAuthResponse {
|
||||
fn into_response(self) -> Response {
|
||||
let (success, needs_totp, redirect_uri, error) = match self {
|
||||
Self::Redirect(uri) => (true, None, Some(uri), None),
|
||||
Self::NeedsTotp(uri) => (true, Some(true), Some(uri), None),
|
||||
Self::Error(msg) => (false, None, None, Some(msg)),
|
||||
Self::TotpError(msg) => (false, Some(true), None, Some(msg)),
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Body {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
needs_totp: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
redirect_uri: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
Json(Body {
|
||||
success,
|
||||
needs_totp,
|
||||
redirect_uri,
|
||||
error,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delegation_auth(
|
||||
@@ -41,230 +183,151 @@ pub async fn delegation_auth(
|
||||
Json(form): Json<DelegationAuthSubmit>,
|
||||
) -> Response {
|
||||
let client_ip = rate_limit.client_ip();
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request = match state
|
||||
.oauth_repo
|
||||
.get_authorization_request(&request_id)
|
||||
.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 request = match get_auth_request(&state, &form.request_uri).await {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let delegated_did: Did = if let Some(did_str) = form.delegated_did.as_ref() {
|
||||
match did_str.parse() {
|
||||
let delegated_did = if let Some(did_str) = form.delegated_did.as_ref() {
|
||||
match parse_did(did_str, "delegated") {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid delegated DID".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(resp) => return resp,
|
||||
}
|
||||
} else if let Some(did) = request.did.as_ref() {
|
||||
did.clone()
|
||||
} else if let Some(did) = request.did.clone() {
|
||||
did
|
||||
} else {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("No delegated account selected".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
return DelegationAuthResponse::err("No delegated account selected");
|
||||
};
|
||||
|
||||
let controller_did: Did = match form.controller_did.parse() {
|
||||
let controller_did = match parse_did(&form.controller_did, "controller") {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid controller DID".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_request_did(&request_id, &delegated_did)
|
||||
let grant = match get_delegation_grant(&state, &delegated_did, &controller_did).await {
|
||||
Ok(g) => g,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let is_cross_pds = form.auth_method.as_deref() == Some("cross_pds");
|
||||
let controller_local = state
|
||||
.user_repo
|
||||
.get_auth_info_by_did(&controller_did)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Failed to update authorization request".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if is_cross_pds || controller_local.is_none() {
|
||||
let did_doc = match state
|
||||
.plc_client()
|
||||
.get_document(controller_did.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(doc) => doc,
|
||||
Err(_) => {
|
||||
return DelegationAuthResponse::err("Failed to resolve controller DID");
|
||||
}
|
||||
};
|
||||
|
||||
let pds_url = match extract_pds_endpoint(&did_doc) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
return DelegationAuthResponse::err("Controller has no PDS endpoint");
|
||||
}
|
||||
};
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let urls = delegation_oauth_urls(hostname);
|
||||
let login_hint = extract_handle(&did_doc);
|
||||
let (par_result, auth_state, oauth_state) = match state
|
||||
.cross_pds_oauth
|
||||
.initiate_par(
|
||||
&pds_url,
|
||||
&urls,
|
||||
login_hint.as_deref(),
|
||||
&form.request_uri,
|
||||
&controller_did,
|
||||
&delegated_did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::error!("Cross-PDS PAR failed: {:?}", e);
|
||||
return DelegationAuthResponse::err("Failed to initiate cross-PDS authentication");
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.cross_pds_oauth
|
||||
.store_auth_state(&oauth_state, &auth_state)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to store cross-PDS auth state: {:?}", e);
|
||||
return DelegationAuthResponse::err(
|
||||
"Internal error preparing cross-PDS authentication",
|
||||
);
|
||||
}
|
||||
|
||||
return DelegationAuthResponse::redirect(par_result.authorize_url);
|
||||
}
|
||||
|
||||
let grant = match state
|
||||
.delegation_repo
|
||||
.get_delegation(&delegated_did, &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 state.user_repo.get_auth_info_by_did(&controller_did).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();
|
||||
}
|
||||
};
|
||||
let controller = controller_local.unwrap();
|
||||
|
||||
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();
|
||||
return DelegationAuthResponse::err("Controller account is deactivated");
|
||||
}
|
||||
|
||||
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();
|
||||
return DelegationAuthResponse::err("Controller account has been taken down");
|
||||
}
|
||||
|
||||
let password = match form.password {
|
||||
Some(ref pw) => pw,
|
||||
None => {
|
||||
return DelegationAuthResponse::err("Password required for local controller");
|
||||
}
|
||||
};
|
||||
|
||||
let password_valid = controller
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.map(|hash| bcrypt::verify(&form.password, hash).unwrap_or_default())
|
||||
.map(|hash| bcrypt::verify(password, hash).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !password_valid {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid password".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
return DelegationAuthResponse::err("Invalid password");
|
||||
}
|
||||
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_controller_did(&request_id, &controller_did)
|
||||
.await
|
||||
.is_err()
|
||||
if let Err(resp) =
|
||||
bind_delegation_to_request(&state, &form.request_uri, &delegated_did, &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();
|
||||
return resp;
|
||||
}
|
||||
|
||||
let has_totp = tranquil_api::server::has_totp_enabled(&state, &controller_did).await;
|
||||
if has_totp {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: Some(true),
|
||||
redirect_uri: Some(format!(
|
||||
"/app/oauth/delegation-totp?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response();
|
||||
return DelegationAuthResponse::needs_totp(format!(
|
||||
"/app/oauth/delegation-totp?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
));
|
||||
}
|
||||
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
|
||||
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
&delegated_did,
|
||||
&controller_did,
|
||||
Some(&controller_did),
|
||||
DelegationActionType::TokenIssued,
|
||||
Some(serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
})),
|
||||
Some(client_ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
finalize_delegation_auth(
|
||||
&state,
|
||||
&form.request_uri,
|
||||
&delegated_did,
|
||||
&controller_did,
|
||||
serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
}),
|
||||
Some(client_ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -280,146 +343,48 @@ pub async fn delegation_totp_verify(
|
||||
Json(form): Json<DelegationTotpSubmit>,
|
||||
) -> Response {
|
||||
let client_ip = rate_limit.client_ip();
|
||||
let totp_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request = match state
|
||||
.oauth_repo
|
||||
.get_authorization_request(&totp_request_id)
|
||||
.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 request = match get_auth_request(&state, &form.request_uri).await {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let controller_did_str = 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 controller_did = match request.controller_did {
|
||||
Some(did) => did,
|
||||
None => return DelegationAuthResponse::err("Controller not authenticated"),
|
||||
};
|
||||
|
||||
let controller_did: Did = match controller_did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid controller DID".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
let delegated_did = match request.did {
|
||||
Some(did) => did,
|
||||
None => return DelegationAuthResponse::err("No delegated account"),
|
||||
};
|
||||
|
||||
let delegated_did_str = 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 delegated_did: Did = match delegated_did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid delegated DID".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let grant = match state
|
||||
.delegation_repo
|
||||
.get_delegation(&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 grant = match get_delegation_grant(&state, &delegated_did, &controller_did).await {
|
||||
Ok(g) => g,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let totp_valid =
|
||||
tranquil_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();
|
||||
return DelegationAuthResponse::totp_error("Invalid TOTP code");
|
||||
}
|
||||
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
|
||||
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
&delegated_did,
|
||||
&controller_did,
|
||||
Some(&controller_did),
|
||||
DelegationActionType::TokenIssued,
|
||||
Some(serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
})),
|
||||
Some(client_ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
finalize_delegation_auth(
|
||||
&state,
|
||||
&form.request_uri,
|
||||
&delegated_did,
|
||||
&controller_did,
|
||||
serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes
|
||||
}),
|
||||
Some(client_ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -436,133 +401,184 @@ pub async fn delegation_auth_token(
|
||||
) -> Response {
|
||||
let controller_did = &auth.did;
|
||||
|
||||
let delegated_did: Did = match form.delegated_did.parse() {
|
||||
let delegated_did = match parse_did(&form.delegated_did, "delegated") {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Invalid delegated DID".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request = match state
|
||||
.oauth_repo
|
||||
.get_authorization_request(&request_id)
|
||||
.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 request = match get_auth_request(&state, &form.request_uri).await {
|
||||
Ok(r) => r,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let grant = match state
|
||||
.delegation_repo
|
||||
.get_delegation(&delegated_did, 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 grant = match get_delegation_grant(&state, &delegated_did, controller_did).await {
|
||||
Ok(g) => g,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_request_did(&request_id, &delegated_did)
|
||||
.await
|
||||
.is_err()
|
||||
if let Err(resp) =
|
||||
bind_delegation_to_request(&state, &form.request_uri, &delegated_did, 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();
|
||||
}
|
||||
|
||||
if state
|
||||
.oauth_repo
|
||||
.set_controller_did(&request_id, controller_did)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Json(DelegationAuthResponse {
|
||||
success: false,
|
||||
needs_totp: None,
|
||||
redirect_uri: None,
|
||||
error: Some("Failed to update authorization request".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
return resp;
|
||||
}
|
||||
|
||||
let ip = extract_client_ip(&headers, None);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
|
||||
|
||||
finalize_delegation_auth(
|
||||
&state,
|
||||
&form.request_uri,
|
||||
&delegated_did,
|
||||
controller_did,
|
||||
serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes,
|
||||
"auth_method": "token"
|
||||
}),
|
||||
Some(&ip),
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CrossPdsCallbackParams {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
pub iss: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn delegation_callback(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<LoginLimit>,
|
||||
Query(params): Query<CrossPdsCallbackParams>,
|
||||
) -> Response {
|
||||
let auth_state = match state
|
||||
.cross_pds_oauth
|
||||
.retrieve_auth_state(¶ms.state)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to retrieve cross-PDS auth state: {:?}", e);
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Cross-PDS auth state expired or invalid",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref expected_issuer) = auth_state.expected_issuer {
|
||||
match ¶ms.iss {
|
||||
Some(iss) if iss != expected_issuer => {
|
||||
tracing::error!(
|
||||
"Cross-PDS issuer mismatch: expected {}, got {}",
|
||||
expected_issuer,
|
||||
iss
|
||||
);
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"Authorization server issuer mismatch",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
None => {
|
||||
tracing::error!(
|
||||
"Cross-PDS callback missing iss parameter (expected {}), possible mix-up attack",
|
||||
expected_issuer
|
||||
);
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Missing required iss parameter",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let urls = delegation_oauth_urls(hostname);
|
||||
|
||||
let returned_sub = match state
|
||||
.cross_pds_oauth
|
||||
.exchange_code(
|
||||
&auth_state,
|
||||
¶ms.code,
|
||||
&urls.client_id,
|
||||
&urls.redirect_uri,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sub) => sub,
|
||||
Err(e) => {
|
||||
tracing::error!("Cross-PDS token exchange failed: {:?}", e);
|
||||
return (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
"Controller authentication failed",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if returned_sub != auth_state.controller_did.as_str() {
|
||||
tracing::error!(
|
||||
"Cross-PDS DID mismatch: expected {}, got {}",
|
||||
auth_state.controller_did,
|
||||
returned_sub
|
||||
);
|
||||
return (axum::http::StatusCode::FORBIDDEN, "Controller DID mismatch").into_response();
|
||||
}
|
||||
|
||||
let delegated_did = &auth_state.delegated_did;
|
||||
let controller_did = &auth_state.controller_did;
|
||||
|
||||
if let Err(_) = get_delegation_grant(&state, delegated_did, controller_did).await {
|
||||
tracing::warn!(
|
||||
"Delegation grant revoked during cross-PDS auth: {} -> {}",
|
||||
controller_did,
|
||||
delegated_did
|
||||
);
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"Delegation grant has been revoked",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(resp) = bind_delegation_to_request(
|
||||
&state,
|
||||
&auth_state.original_request_uri,
|
||||
delegated_did,
|
||||
controller_did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return resp;
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
&delegated_did,
|
||||
delegated_did,
|
||||
controller_did,
|
||||
Some(controller_did),
|
||||
DelegationActionType::TokenIssued,
|
||||
Some(serde_json::json!({
|
||||
"client_id": request.client_id,
|
||||
"granted_scopes": grant.granted_scopes,
|
||||
"auth_method": "token"
|
||||
"auth_method": "cross_pds",
|
||||
"controller_pds": auth_state.controller_pds_url
|
||||
})),
|
||||
Some(&ip),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Json(DelegationAuthResponse {
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
})
|
||||
.into_response()
|
||||
Redirect::temporary(&consent_url(&auth_state.original_request_uri)).into_response()
|
||||
}
|
||||
|
||||
pub async fn delegation_client_metadata(State(_state): State<AppState>) -> Response {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let metadata = build_client_metadata(hostname);
|
||||
Json(metadata).into_response()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::Utc;
|
||||
use hmac::Mac;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300;
|
||||
@@ -17,10 +17,7 @@ pub struct TokenClaims {
|
||||
}
|
||||
|
||||
pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let computed_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
let computed_challenge = tranquil_pds::oauth::compute_pkce_challenge(code_verifier);
|
||||
if !bool::from(
|
||||
computed_challenge
|
||||
.as_bytes()
|
||||
|
||||
@@ -65,6 +65,14 @@ pub fn oauth_routes() -> axum::Router<AppState> {
|
||||
"/delegation/totp",
|
||||
post(endpoints::delegation_totp_verify),
|
||||
)
|
||||
.route(
|
||||
"/delegation/callback",
|
||||
get(endpoints::delegation_callback),
|
||||
)
|
||||
.route(
|
||||
"/delegation/client-metadata",
|
||||
get(endpoints::delegation_client_metadata),
|
||||
)
|
||||
.route("/token", post(endpoints::token_endpoint))
|
||||
.route("/revoke", post(endpoints::revoke_token))
|
||||
.route("/introspect", post(endpoints::introspect_token))
|
||||
|
||||
@@ -19,13 +19,6 @@ use tranquil_pds::rate_limit::{
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
fn generate_state() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn generate_nonce() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 16];
|
||||
@@ -129,7 +122,7 @@ pub async fn sso_initiate(
|
||||
}
|
||||
};
|
||||
|
||||
let sso_state = generate_state();
|
||||
let sso_state = tranquil_pds::util::generate_random_token();
|
||||
let nonce = generate_nonce();
|
||||
let redirect_uri = SsoConfig::get_redirect_uri();
|
||||
|
||||
@@ -1323,7 +1316,7 @@ pub async fn complete_registration(
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
scope: Some("transition:generic".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
|
||||
@@ -385,6 +385,87 @@ pub fn compute_access_token_hash(access_token: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(hash)
|
||||
}
|
||||
|
||||
pub fn compute_pkce_challenge(verifier: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn es256_signing_key_to_jwk(key: &p256::ecdsa::SigningKey) -> Result<DPoPJwk, OAuthError> {
|
||||
let point = key.verifying_key().to_encoded_point(false);
|
||||
let x = URL_SAFE_NO_PAD.encode(
|
||||
point
|
||||
.x()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("invalid EC key: missing x".into()))?,
|
||||
);
|
||||
let y = URL_SAFE_NO_PAD.encode(
|
||||
point
|
||||
.y()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("invalid EC key: missing y".into()))?,
|
||||
);
|
||||
Ok(DPoPJwk {
|
||||
kty: "EC".to_string(),
|
||||
crv: Some("P-256".to_string()),
|
||||
x: Some(x),
|
||||
y: Some(y),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_dpop_proof(
|
||||
signing_key: &p256::ecdsa::SigningKey,
|
||||
method: &str,
|
||||
url: &str,
|
||||
nonce: Option<&str>,
|
||||
access_token_hash: Option<&str>,
|
||||
) -> Result<String, OAuthError> {
|
||||
use p256::ecdsa::signature::Signer;
|
||||
|
||||
let jwk = es256_signing_key_to_jwk(signing_key)?;
|
||||
|
||||
let header = serde_json::json!({
|
||||
"typ": "dpop+jwt",
|
||||
"alg": "ES256",
|
||||
"jwk": jwk
|
||||
});
|
||||
|
||||
let jti = {
|
||||
use rand::Rng;
|
||||
let bytes: [u8; 16] = rand::thread_rng().r#gen();
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
};
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"jti": jti,
|
||||
"htm": method,
|
||||
"htu": url,
|
||||
"iat": Utc::now().timestamp()
|
||||
});
|
||||
if let Some(n) = nonce {
|
||||
payload["nonce"] = serde_json::Value::String(n.to_string());
|
||||
}
|
||||
if let Some(ath) = access_token_hash {
|
||||
payload["ath"] = serde_json::Value::String(ath.to_string());
|
||||
}
|
||||
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&header).map_err(|e| OAuthError::InvalidDpopProof(e.to_string()))?,
|
||||
);
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&payload).map_err(|e| OAuthError::InvalidDpopProof(e.to_string()))?,
|
||||
);
|
||||
|
||||
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
||||
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
|
||||
}
|
||||
|
||||
pub fn compute_es256_jkt(signing_key: &p256::ecdsa::SigningKey) -> Result<String, OAuthError> {
|
||||
let jwk = es256_signing_key_to_jwk(signing_key)?;
|
||||
compute_jwk_thumbprint(&jwk)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -6,7 +6,9 @@ mod types;
|
||||
pub use client::{ClientMetadata, ClientMetadataCache, verify_client_auth};
|
||||
pub use dpop::{
|
||||
DPoPJwk, DPoPProofHeader, DPoPProofPayload, DPoPVerifier, DPoPVerifyResult,
|
||||
compute_access_token_hash, compute_jwk_thumbprint,
|
||||
compute_access_token_hash, compute_es256_jkt, compute_jwk_thumbprint, compute_pkce_challenge,
|
||||
create_dpop_proof,
|
||||
es256_signing_key_to_jwk,
|
||||
};
|
||||
pub use error::OAuthError;
|
||||
pub use types::{
|
||||
|
||||
@@ -308,6 +308,31 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
|
||||
validate_service_handle(handle, ReservedHandlePolicy::Reject)
|
||||
}
|
||||
|
||||
pub fn resolve_handle_input(input: &str) -> Result<String, HandleValidationError> {
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
if !input.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(input),
|
||||
None => input,
|
||||
};
|
||||
let validated = validate_short_handle(handle_to_validate)?;
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
validated,
|
||||
matched_domain.unwrap_or(&available_domains[0])
|
||||
))
|
||||
} else {
|
||||
validate_full_domain_handle(input)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_service_handle(
|
||||
handle: &str,
|
||||
reserved_policy: ReservedHandlePolicy,
|
||||
|
||||
@@ -206,7 +206,7 @@ impl AuthenticatedUser {
|
||||
return ScopePermissions::from_scope_string(Some(scope));
|
||||
}
|
||||
if !self.is_oauth() {
|
||||
return ScopePermissions::from_scope_string(Some("atproto"));
|
||||
return ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky"));
|
||||
}
|
||||
ScopePermissions::from_scope_string(self.scope.as_deref())
|
||||
}
|
||||
|
||||
@@ -2,11 +2,50 @@ pub mod roles;
|
||||
pub mod scopes;
|
||||
|
||||
pub use roles::{
|
||||
CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers,
|
||||
verify_can_be_controller, verify_can_control_accounts,
|
||||
CanAddControllers, CanControlAccounts, verify_can_add_controllers,
|
||||
verify_can_control_accounts,
|
||||
};
|
||||
pub use scopes::{
|
||||
InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope,
|
||||
intersect_scopes, validate_delegation_scopes,
|
||||
intersect_scopes,
|
||||
};
|
||||
pub use tranquil_db_traits::DelegationActionType;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedIdentity {
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pds_url: Option<String>,
|
||||
pub is_local: bool,
|
||||
}
|
||||
|
||||
pub async fn resolve_identity(state: &AppState, did: &Did) -> Option<ResolvedIdentity> {
|
||||
let is_local = state
|
||||
.user_repo
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
let did_doc = state
|
||||
.did_resolver
|
||||
.resolve_did_document(did.as_str())
|
||||
.await?;
|
||||
|
||||
let pds_url = tranquil_types::did_doc::extract_pds_endpoint(&did_doc);
|
||||
let handle = tranquil_types::did_doc::extract_handle(&did_doc);
|
||||
|
||||
Some(ResolvedIdentity {
|
||||
did: did.clone(),
|
||||
handle,
|
||||
pds_url,
|
||||
is_local,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
@@ -5,54 +7,37 @@ use crate::auth::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
pub struct CanAddControllers<'a> {
|
||||
pub struct AddControllersTag;
|
||||
pub struct ControlAccountsTag;
|
||||
|
||||
pub struct DelegationProof<'a, Tag> {
|
||||
user: &'a AuthenticatedUser,
|
||||
_tag: PhantomData<Tag>,
|
||||
}
|
||||
|
||||
pub struct CanControlAccounts<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
}
|
||||
pub type CanAddControllers<'a> = DelegationProof<'a, AddControllersTag>;
|
||||
pub type CanControlAccounts<'a> = DelegationProof<'a, ControlAccountsTag>;
|
||||
|
||||
pub struct CanBeController<'a> {
|
||||
controller_did: &'a Did,
|
||||
}
|
||||
|
||||
impl<'a> CanAddControllers<'a> {
|
||||
impl<'a, Tag> DelegationProof<'a, Tag> {
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CanControlAccounts<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CanBeController<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
self.controller_did
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_can_add_controllers<'a>(
|
||||
async fn check_delegation_flag(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<CanAddControllers<'a>, Response> {
|
||||
match state.delegation_repo.controls_any_accounts(&user.did).await {
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot add controllers to an account that controls other accounts".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanAddControllers { user }),
|
||||
did: &Did,
|
||||
check_is_delegated: bool,
|
||||
error_msg: &str,
|
||||
) -> Result<bool, Response> {
|
||||
let result = if check_is_delegated {
|
||||
state.delegation_repo.is_delegated_account(did).await
|
||||
} else {
|
||||
state.delegation_repo.controls_any_accounts(did).await
|
||||
};
|
||||
match result {
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(error_msg.into()).into_response()),
|
||||
Ok(false) => Ok(false),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check delegation status: {:?}", e);
|
||||
Err(
|
||||
@@ -63,46 +48,37 @@ pub async fn verify_can_add_controllers<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_can_add_controllers<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<CanAddControllers<'a>, Response> {
|
||||
check_delegation_flag(
|
||||
state,
|
||||
&user.did,
|
||||
false,
|
||||
"Cannot add controllers to an account that controls other accounts",
|
||||
)
|
||||
.await?;
|
||||
Ok(DelegationProof {
|
||||
user,
|
||||
_tag: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn verify_can_control_accounts<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<CanControlAccounts<'a>, Response> {
|
||||
match state.delegation_repo.has_any_controllers(&user.did).await {
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot create delegated accounts from a controlled account".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanControlAccounts { user }),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
check_delegation_flag(
|
||||
state,
|
||||
&user.did,
|
||||
true,
|
||||
"Cannot create delegated accounts from a controlled account",
|
||||
)
|
||||
.await?;
|
||||
Ok(DelegationProof {
|
||||
user,
|
||||
_tag: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn verify_can_be_controller<'a>(
|
||||
state: &AppState,
|
||||
controller_did: &'a Did,
|
||||
) -> Result<CanBeController<'a>, Response> {
|
||||
match state
|
||||
.delegation_repo
|
||||
.has_any_controllers(controller_did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanBeController { controller_did }),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub use tranquil_db_traits::{
|
||||
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ScopePreset {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
@@ -50,57 +51,94 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String {
|
||||
let requested_has_atproto = requested_set.contains("atproto");
|
||||
|
||||
if granted_has_atproto {
|
||||
return requested_set.into_iter().collect::<Vec<_>>().join(" ");
|
||||
let mut scopes: Vec<&str> = requested_set.into_iter().collect();
|
||||
scopes.sort();
|
||||
return scopes.join(" ");
|
||||
}
|
||||
|
||||
if requested_has_atproto {
|
||||
return granted_set.into_iter().collect::<Vec<_>>().join(" ");
|
||||
let mut scopes: Vec<&str> = granted_set.into_iter().collect();
|
||||
scopes.sort();
|
||||
return scopes.join(" ");
|
||||
}
|
||||
|
||||
let mut result: Vec<&str> = requested_set
|
||||
.iter()
|
||||
.filter_map(|requested_scope| {
|
||||
if granted_set.contains(requested_scope) {
|
||||
Some(*requested_scope)
|
||||
} else {
|
||||
find_matching_scope(requested_scope, &granted_set)
|
||||
}
|
||||
})
|
||||
.filter(|requested_scope| any_granted_covers(requested_scope, &granted_set))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
result.sort();
|
||||
result.join(" ")
|
||||
}
|
||||
|
||||
fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> {
|
||||
fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool {
|
||||
granted
|
||||
.iter()
|
||||
.find(|&granted_scope| scopes_compatible(granted_scope, requested))
|
||||
.map(|v| v as _)
|
||||
.any(|granted_scope| scope_covers(granted_scope, requested))
|
||||
}
|
||||
|
||||
fn scopes_compatible(granted: &str, requested: &str) -> bool {
|
||||
fn scope_covers(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);
|
||||
let (granted_base, granted_params) = split_scope(granted);
|
||||
let (requested_base, requested_params) = split_scope(requested);
|
||||
|
||||
if granted_base.ends_with(":*")
|
||||
let base_matches = if granted_base.ends_with(":*")
|
||||
&& requested_base.starts_with(&granted_base[..granted_base.len() - 1])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(prefix) = granted_base.strip_suffix(".*")
|
||||
true
|
||||
} else if let Some(prefix) = granted_base.strip_suffix(".*")
|
||||
&& requested_base.starts_with(prefix)
|
||||
&& requested_base.len() > prefix.len()
|
||||
{
|
||||
return true;
|
||||
true
|
||||
} else {
|
||||
granted_base == requested_base
|
||||
};
|
||||
|
||||
if !base_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
false
|
||||
match (granted_params, requested_params) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => true,
|
||||
(Some(gp), Some(rp)) => params_cover(gp, rp),
|
||||
}
|
||||
}
|
||||
|
||||
fn params_cover(granted_params: &str, requested_params: &str) -> bool {
|
||||
let granted_kv: HashSet<(&str, &str)> = granted_params
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.collect();
|
||||
let requested_kv: HashSet<(&str, &str)> = requested_params
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.collect();
|
||||
|
||||
let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect();
|
||||
let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect();
|
||||
|
||||
requested_keys.iter().all(|key| {
|
||||
if !granted_keys.contains(key) {
|
||||
return false;
|
||||
}
|
||||
let requested_values: HashSet<&str> = requested_kv
|
||||
.iter()
|
||||
.filter(|(k, _)| k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.collect();
|
||||
let granted_values: HashSet<&str> = granted_kv
|
||||
.iter()
|
||||
.filter(|(k, _)| k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.collect();
|
||||
requested_values.is_subset(&granted_values)
|
||||
})
|
||||
}
|
||||
|
||||
fn split_scope(scope: &str) -> (&str, Option<&str>) {
|
||||
@@ -111,11 +149,6 @@ fn split_scope(scope: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> {
|
||||
ValidatedDelegationScope::new(scopes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,22 +185,98 @@ mod tests {
|
||||
assert_eq!(intersect_scopes("atproto", ""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_returns_requested_not_granted() {
|
||||
let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*");
|
||||
assert_eq!(result, "repo:app.bsky.feed.post?action=create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_wildcard_granted_covers_specific_requested() {
|
||||
let result = intersect_scopes(
|
||||
"repo:app.bsky.feed.post?action=create",
|
||||
"repo:*?action=create repo:*?action=update blob:*/*",
|
||||
);
|
||||
assert_eq!(result, "repo:app.bsky.feed.post?action=create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_mismatched_params_rejects() {
|
||||
let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_granted_no_params_covers_requested_with_params() {
|
||||
let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*");
|
||||
assert_eq!(result, "repo:app.bsky.feed.post?action=create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_granted_with_params_covers_requested_no_params() {
|
||||
let result =
|
||||
intersect_scopes("repo:app.bsky.feed.post", "repo:*?action=create&action=delete");
|
||||
assert_eq!(result, "repo:app.bsky.feed.post");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_multi_action_subset() {
|
||||
let result = intersect_scopes(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=create&action=update&action=delete",
|
||||
);
|
||||
assert_eq!(result, "repo:*?action=create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_base_only() {
|
||||
assert!(scope_covers("repo:*", "repo:app.bsky.feed.post"));
|
||||
assert!(scope_covers("repo:*", "repo:app.bsky.feed.post?action=create"));
|
||||
assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_params() {
|
||||
assert!(scope_covers(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=create"
|
||||
));
|
||||
assert!(!scope_covers(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=delete"
|
||||
));
|
||||
assert!(scope_covers(
|
||||
"repo:*?action=create&action=delete",
|
||||
"repo:*?action=create"
|
||||
));
|
||||
assert!(!scope_covers(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=create&action=delete"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_no_granted_params_means_all() {
|
||||
assert!(scope_covers("repo:*", "repo:*?action=create"));
|
||||
assert!(scope_covers("repo:*", "repo:*?action=delete"));
|
||||
}
|
||||
|
||||
#[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());
|
||||
assert!(ValidatedDelegationScope::new("atproto").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("repo:* blob:*/*").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_scopes_invalid() {
|
||||
assert!(validate_delegation_scopes("invalid:scope").is_err());
|
||||
assert!(ValidatedDelegationScope::new("invalid:scope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_presets_parse() {
|
||||
SCOPE_PRESETS.iter().for_each(|p| {
|
||||
validate_delegation_scopes(p.scopes).unwrap_or_else(|e| {
|
||||
ValidatedDelegationScope::new(p.scopes).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"preset '{}' has invalid scopes '{}': {}",
|
||||
p.name, p.scopes, e
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p256::ecdsa::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tranquil_oauth::{
|
||||
AuthorizationServerMetadata, ClientMetadata, compute_es256_jkt, compute_pkce_challenge,
|
||||
create_dpop_proof,
|
||||
};
|
||||
use tranquil_types::Did;
|
||||
|
||||
use crate::cache::Cache;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CrossPdsError {
|
||||
#[error("failed to fetch OAuth metadata: {0}")]
|
||||
MetadataFetch(String),
|
||||
#[error("controller PDS has no PAR endpoint")]
|
||||
NoParEndpoint,
|
||||
#[error("PAR request failed: {0}")]
|
||||
ParFailed(String),
|
||||
#[error("token exchange failed: {0}")]
|
||||
TokenExchangeFailed(String),
|
||||
#[error("invalid token response: {0}")]
|
||||
InvalidTokenResponse(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CrossPdsAuthState {
|
||||
pub original_request_uri: String,
|
||||
pub controller_did: Did,
|
||||
pub controller_pds_url: String,
|
||||
pub code_verifier: String,
|
||||
pub dpop_private_key_der: String,
|
||||
pub delegated_did: Did,
|
||||
pub expected_issuer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParResult {
|
||||
pub request_uri: String,
|
||||
pub authorize_url: String,
|
||||
}
|
||||
|
||||
pub struct DelegationOAuthUrls {
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
pub fn delegation_oauth_urls(hostname: &str) -> DelegationOAuthUrls {
|
||||
DelegationOAuthUrls {
|
||||
client_id: format!("https://{}/oauth/delegation/client-metadata", hostname),
|
||||
redirect_uri: format!("https://{}/oauth/delegation/callback", hostname),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CrossPdsOAuthClient {
|
||||
http: Client,
|
||||
cache: Arc<dyn Cache>,
|
||||
}
|
||||
|
||||
impl CrossPdsOAuthClient {
|
||||
pub fn new(cache: Arc<dyn Cache>) -> Self {
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self { http, cache }
|
||||
}
|
||||
|
||||
pub async fn store_auth_state(
|
||||
&self,
|
||||
state_key: &str,
|
||||
auth_state: &CrossPdsAuthState,
|
||||
) -> Result<(), CrossPdsError> {
|
||||
let cache_key = format!("cross_pds_state:{}", state_key);
|
||||
let json_bytes = serde_json::to_vec(auth_state)
|
||||
.map_err(|e| CrossPdsError::ParFailed(format!("serialize auth state: {}", e)))?;
|
||||
let encrypted = crate::config::encrypt_key(&json_bytes)
|
||||
.map_err(|e| CrossPdsError::ParFailed(format!("encrypt auth state: {}", e)))?;
|
||||
self.cache
|
||||
.set_bytes(&cache_key, &encrypted, Duration::from_secs(600))
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::ParFailed(format!("cache auth state: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn retrieve_auth_state(
|
||||
&self,
|
||||
state_key: &str,
|
||||
) -> Result<CrossPdsAuthState, CrossPdsError> {
|
||||
let cache_key = format!("cross_pds_state:{}", state_key);
|
||||
let encrypted_bytes = self
|
||||
.cache
|
||||
.get_bytes(&cache_key)
|
||||
.await
|
||||
.ok_or_else(|| CrossPdsError::TokenExchangeFailed("auth state expired or not found".into()))?;
|
||||
let _ = self.cache.delete(&cache_key).await;
|
||||
let decrypted = crate::config::decrypt_key(
|
||||
&encrypted_bytes,
|
||||
Some(crate::config::ENCRYPTION_VERSION),
|
||||
)
|
||||
.map_err(|e| CrossPdsError::TokenExchangeFailed(format!("decrypt auth state: {}", e)))?;
|
||||
serde_json::from_slice(&decrypted)
|
||||
.map_err(|e| CrossPdsError::TokenExchangeFailed(format!("deserialize auth state: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn check_remote_is_delegated(&self, pds_url: &str, did: &str) -> Option<bool> {
|
||||
let url = format!(
|
||||
"{}/oauth/security-status?identifier={}",
|
||||
pds_url.trim_end_matches('/'),
|
||||
urlencoding::encode(did)
|
||||
);
|
||||
let resp = self.http.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemoteSecurityStatus {
|
||||
is_delegated: Option<bool>,
|
||||
}
|
||||
resp.json::<RemoteSecurityStatus>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| s.is_delegated)
|
||||
}
|
||||
|
||||
async fn send_with_dpop_retry(
|
||||
&self,
|
||||
signing_key: &SigningKey,
|
||||
method: &str,
|
||||
url: &str,
|
||||
params: &[(&str, String)],
|
||||
access_token_hash: Option<&str>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let make_proof = |nonce: Option<&str>| {
|
||||
create_dpop_proof(signing_key, method, url, nonce, access_token_hash)
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
};
|
||||
|
||||
let resp = self.http.post(url).header("DPoP", &make_proof(None)?).form(params)
|
||||
.send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let nonce = resp.headers().get("dpop-nonce")
|
||||
.and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||
let needs_retry = matches!(
|
||||
resp.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
if needs_retry && nonce.is_some() {
|
||||
return self.http.post(url).header("DPoP", &make_proof(nonce.as_deref())?)
|
||||
.form(params).send().await.map_err(|e| e.to_string());
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn require_https(url: &str, label: &str) -> Result<(), CrossPdsError> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(CrossPdsError::MetadataFetch(format!(
|
||||
"{} must use HTTPS, got: {}",
|
||||
label, url
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_authorization_server(&self, pds_url: &str) -> Result<String, CrossPdsError> {
|
||||
Self::require_https(pds_url, "PDS URL")?;
|
||||
|
||||
let resource_url = format!(
|
||||
"{}/.well-known/oauth-protected-resource",
|
||||
pds_url.trim_end_matches('/')
|
||||
);
|
||||
if let Ok(resp) = self.http.get(&resource_url).send().await
|
||||
&& resp.status().is_success()
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct ProtectedResource {
|
||||
authorization_servers: Option<Vec<String>>,
|
||||
}
|
||||
if let Ok(pr) = resp.json::<ProtectedResource>().await
|
||||
&& let Some(server) = pr.authorization_servers.and_then(|s| s.into_iter().next())
|
||||
{
|
||||
Self::require_https(&server, "Authorization server")?;
|
||||
return Ok(server);
|
||||
}
|
||||
}
|
||||
Ok(pds_url.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
pub async fn fetch_server_metadata(
|
||||
&self,
|
||||
pds_url: &str,
|
||||
) -> Result<AuthorizationServerMetadata, CrossPdsError> {
|
||||
let cache_key = format!("cross_pds_oauth_meta:{}", pds_url);
|
||||
if let Some(cached) = self.cache.get(&cache_key).await
|
||||
&& let Ok(meta) = serde_json::from_str(&cached)
|
||||
{
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
let auth_server = self.resolve_authorization_server(pds_url).await?;
|
||||
|
||||
let url = format!("{}/.well-known/oauth-authorization-server", auth_server);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(CrossPdsError::MetadataFetch(format!(
|
||||
"HTTP {} from {}",
|
||||
resp.status(),
|
||||
url
|
||||
)));
|
||||
}
|
||||
|
||||
let meta: AuthorizationServerMetadata = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?;
|
||||
|
||||
if let Ok(json_str) = serde_json::to_string(&meta) {
|
||||
let _ = self
|
||||
.cache
|
||||
.set(&cache_key, &json_str, Duration::from_secs(300))
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
pub async fn initiate_par(
|
||||
&self,
|
||||
pds_url: &str,
|
||||
urls: &DelegationOAuthUrls,
|
||||
login_hint: Option<&str>,
|
||||
original_request_uri: &str,
|
||||
controller_did: &Did,
|
||||
delegated_did: &Did,
|
||||
) -> Result<(ParResult, CrossPdsAuthState, String), CrossPdsError> {
|
||||
let meta = self.fetch_server_metadata(pds_url).await?;
|
||||
let par_endpoint = meta
|
||||
.pushed_authorization_request_endpoint
|
||||
.as_deref()
|
||||
.ok_or(CrossPdsError::NoParEndpoint)?;
|
||||
|
||||
let code_verifier = crate::util::generate_random_token();
|
||||
let code_challenge = compute_pkce_challenge(&code_verifier);
|
||||
let state = crate::util::generate_random_token();
|
||||
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
let dpop_key_der = URL_SAFE_NO_PAD.encode(signing_key.to_bytes());
|
||||
|
||||
let dpop_jkt = compute_es256_jkt(&signing_key)
|
||||
.map_err(|e| CrossPdsError::ParFailed(format!("{:?}", e)))?;
|
||||
|
||||
let mut params = vec![
|
||||
("response_type", "code".to_string()),
|
||||
("client_id", urls.client_id.clone()),
|
||||
("redirect_uri", urls.redirect_uri.clone()),
|
||||
("scope", "atproto".to_string()),
|
||||
("state", state.clone()),
|
||||
("code_challenge", code_challenge),
|
||||
("code_challenge_method", "S256".to_string()),
|
||||
("dpop_jkt", dpop_jkt),
|
||||
];
|
||||
if let Some(hint) = login_hint {
|
||||
params.push(("login_hint", hint.to_string()));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.send_with_dpop_retry(&signing_key, "POST", par_endpoint, ¶ms, None)
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::ParFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(CrossPdsError::ParFailed(format!("PAR rejected: {}", body)));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ParResp {
|
||||
request_uri: String,
|
||||
}
|
||||
|
||||
let par_resp: ParResp = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::ParFailed(e.to_string()))?;
|
||||
|
||||
let authorize_url = format!(
|
||||
"{}?request_uri={}&client_id={}",
|
||||
meta.authorization_endpoint,
|
||||
urlencoding::encode(&par_resp.request_uri),
|
||||
urlencoding::encode(&urls.client_id)
|
||||
);
|
||||
|
||||
let auth_state = CrossPdsAuthState {
|
||||
original_request_uri: original_request_uri.to_string(),
|
||||
controller_did: controller_did.clone(),
|
||||
controller_pds_url: pds_url.to_string(),
|
||||
code_verifier,
|
||||
dpop_private_key_der: dpop_key_der,
|
||||
delegated_did: delegated_did.clone(),
|
||||
expected_issuer: Some(meta.issuer.clone()),
|
||||
};
|
||||
|
||||
Ok((
|
||||
ParResult {
|
||||
request_uri: par_resp.request_uri,
|
||||
authorize_url,
|
||||
},
|
||||
auth_state,
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
auth_state: &CrossPdsAuthState,
|
||||
code: &str,
|
||||
client_id: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<String, CrossPdsError> {
|
||||
let meta = self
|
||||
.fetch_server_metadata(&auth_state.controller_pds_url)
|
||||
.await?;
|
||||
|
||||
let key_bytes = URL_SAFE_NO_PAD
|
||||
.decode(&auth_state.dpop_private_key_der)
|
||||
.map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?;
|
||||
let signing_key = SigningKey::from_bytes((&key_bytes[..]).into())
|
||||
.map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?;
|
||||
|
||||
let params = vec![
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
("code", code.to_string()),
|
||||
("redirect_uri", redirect_uri.to_string()),
|
||||
("code_verifier", auth_state.code_verifier.clone()),
|
||||
("client_id", client_id.to_string()),
|
||||
];
|
||||
|
||||
let resp = self
|
||||
.send_with_dpop_retry(&signing_key, "POST", &meta.token_endpoint, ¶ms, None)
|
||||
.await
|
||||
.map_err(CrossPdsError::TokenExchangeFailed)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(CrossPdsError::TokenExchangeFailed(format!(
|
||||
"Token exchange rejected: {}",
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResp {
|
||||
sub: Option<String>,
|
||||
token_type: Option<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
let token_resp: TokenResp = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CrossPdsError::InvalidTokenResponse(e.to_string()))?;
|
||||
|
||||
if let Some(ref err) = token_resp.error {
|
||||
let desc = token_resp.error_description.as_deref().unwrap_or("unknown");
|
||||
return Err(CrossPdsError::TokenExchangeFailed(format!(
|
||||
"{}: {}",
|
||||
err, desc
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(ref tt) = token_resp.token_type
|
||||
&& !tt.eq_ignore_ascii_case("DPoP")
|
||||
{
|
||||
return Err(CrossPdsError::InvalidTokenResponse(format!(
|
||||
"expected token_type DPoP, got {}",
|
||||
tt
|
||||
)));
|
||||
}
|
||||
|
||||
token_resp
|
||||
.sub
|
||||
.ok_or_else(|| CrossPdsError::InvalidTokenResponse("missing sub claim".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_client_metadata(hostname: &str) -> ClientMetadata {
|
||||
let urls = delegation_oauth_urls(hostname);
|
||||
ClientMetadata {
|
||||
client_id: urls.client_id,
|
||||
client_name: Some(hostname.to_string()),
|
||||
client_uri: Some(format!("https://{}", hostname)),
|
||||
redirect_uris: vec![urls.redirect_uri],
|
||||
grant_types: vec!["authorization_code".to_string()],
|
||||
response_types: vec!["code".to_string()],
|
||||
scope: Some("atproto".to_string()),
|
||||
dpop_bound_access_tokens: Some(true),
|
||||
token_endpoint_auth_method: Some("none".to_string()),
|
||||
application_type: Some("web".to_string()),
|
||||
..ClientMetadata::default()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod client;
|
||||
pub mod db;
|
||||
pub mod scopes;
|
||||
pub mod verify;
|
||||
@@ -16,7 +17,7 @@ pub use tranquil_oauth::{
|
||||
OAuthError, ParResponse, Prompt, ProtectedResourceMetadata, RefreshToken, RefreshTokenState,
|
||||
RequestData, RequestId, ResponseMode, ResponseType, SessionId, TokenData, TokenId,
|
||||
TokenRequest, TokenResponse, compute_access_token_hash, compute_jwk_thumbprint,
|
||||
verify_client_auth,
|
||||
compute_pkce_challenge, verify_client_auth,
|
||||
};
|
||||
|
||||
pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions};
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::auth::webauthn::WebAuthnConfig;
|
||||
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
|
||||
use crate::circuit_breaker::CircuitBreakers;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::oauth::client::CrossPdsOAuthClient;
|
||||
use crate::plc::PlcClient;
|
||||
use crate::rate_limit::RateLimiters;
|
||||
use crate::repo::PostgresBlockStore;
|
||||
use crate::repo_write_lock::RepoWriteLocks;
|
||||
@@ -57,6 +59,7 @@ pub struct AppState {
|
||||
pub sso_repo: Arc<dyn SsoRepository>,
|
||||
pub sso_manager: SsoManager,
|
||||
pub webauthn_config: Arc<WebAuthnConfig>,
|
||||
pub cross_pds_oauth: Arc<CrossPdsOAuthClient>,
|
||||
pub shutdown: CancellationToken,
|
||||
pub bootstrap_invite_code: Option<String>,
|
||||
}
|
||||
@@ -204,6 +207,10 @@ impl RateLimitKind {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn plc_client(&self) -> PlcClient {
|
||||
PlcClient::with_cache(None, Some(self.cache.clone()))
|
||||
}
|
||||
|
||||
pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> {
|
||||
let cfg = tranquil_config::get();
|
||||
let database_url = &cfg.database.url;
|
||||
@@ -272,6 +279,7 @@ impl AppState {
|
||||
let circuit_breakers = Arc::new(CircuitBreakers::new());
|
||||
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone()).await;
|
||||
let did_resolver = Arc::new(DidResolver::new());
|
||||
let cross_pds_oauth = Arc::new(CrossPdsOAuthClient::new(cache.clone()));
|
||||
let sso_config = SsoConfig::init();
|
||||
let sso_manager = SsoManager::from_config(sso_config);
|
||||
let webauthn_config = Arc::new(
|
||||
@@ -302,6 +310,7 @@ impl AppState {
|
||||
cache,
|
||||
distributed_rate_limiter,
|
||||
did_resolver,
|
||||
cross_pds_oauth,
|
||||
sso_manager,
|
||||
webauthn_config,
|
||||
shutdown,
|
||||
|
||||
@@ -89,6 +89,20 @@ pub fn get_header_str(
|
||||
headers.get(name).and_then(|h| h.to_str().ok())
|
||||
}
|
||||
|
||||
pub fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub fn generate_random_token() -> String {
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let bytes: [u8; 32] = rand::thread_rng().r#gen();
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
@@ -183,10 +197,9 @@ pub fn json_to_ipld(value: &JsonValue) -> Ipld {
|
||||
}
|
||||
if let Some(JsonValue::String(b64)) = obj.get("$bytes")
|
||||
&& obj.len() == 1
|
||||
&& let Ok(bytes) = BASE64_STANDARD_INDIFFERENT.decode(b64)
|
||||
{
|
||||
if let Ok(bytes) = BASE64_STANDARD_INDIFFERENT.decode(b64) {
|
||||
return Ipld::Bytes(bytes);
|
||||
}
|
||||
return Ipld::Bytes(bytes);
|
||||
}
|
||||
let map: BTreeMap<String, Ipld> = obj
|
||||
.iter()
|
||||
|
||||
@@ -83,7 +83,7 @@ async fn create_user_and_oauth_session(
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", "atproto"),
|
||||
("scope", "atproto transition:generic"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
@@ -122,7 +122,7 @@ async fn create_user_and_oauth_session(
|
||||
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}))
|
||||
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
|
||||
.send().await.expect("Consent request failed");
|
||||
assert_eq!(
|
||||
consent_res.status(),
|
||||
@@ -631,7 +631,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false}))
|
||||
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
|
||||
.send().await.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
@@ -692,7 +692,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false}))
|
||||
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
|
||||
.send().await.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
|
||||
@@ -131,7 +131,7 @@ async fn create_user_and_oauth_session_with_scope(
|
||||
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}))
|
||||
.json(&json!({"request_uri": request_uri, "approved_scopes": scope.split_whitespace().collect::<Vec<_>>(), "remember": false}))
|
||||
.send().await.expect("Consent request failed");
|
||||
assert_eq!(
|
||||
consent_res.status(),
|
||||
@@ -178,7 +178,7 @@ async fn create_user_and_oauth_session_with_scope(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_atproto_scope_allows_full_access() {
|
||||
async fn test_atproto_scope_denies_repo_writes() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let (session, _mock) = create_user_and_oauth_session_with_scope(
|
||||
@@ -197,7 +197,7 @@ async fn test_atproto_scope_allows_full_access() {
|
||||
"collection": collection,
|
||||
"record": {
|
||||
"$type": collection,
|
||||
"text": "Full access post",
|
||||
"text": "Should be denied",
|
||||
"createdAt": Utc::now().to_rfc3339()
|
||||
}
|
||||
}))
|
||||
@@ -207,59 +207,13 @@ async fn test_atproto_scope_allows_full_access() {
|
||||
|
||||
assert_eq!(
|
||||
create_res.status(),
|
||||
StatusCode::OK,
|
||||
"atproto scope should allow creating records"
|
||||
);
|
||||
let create_body: Value = create_res.json().await.unwrap();
|
||||
let rkey = create_body["uri"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap();
|
||||
|
||||
let put_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
|
||||
.bearer_auth(&session.access_token)
|
||||
.json(&json!({
|
||||
"repo": session.did,
|
||||
"collection": collection,
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": collection,
|
||||
"text": "Updated post",
|
||||
"createdAt": Utc::now().to_rfc3339()
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
put_res.status(),
|
||||
StatusCode::OK,
|
||||
"atproto scope should allow updating records"
|
||||
);
|
||||
|
||||
let delete_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
|
||||
.bearer_auth(&session.access_token)
|
||||
.json(&json!({
|
||||
"repo": session.did,
|
||||
"collection": collection,
|
||||
"rkey": rkey
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
delete_res.status(),
|
||||
StatusCode::OK,
|
||||
"atproto scope should allow deleting records"
|
||||
StatusCode::FORBIDDEN,
|
||||
"atproto scope alone should deny creating records"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_atproto_scope_allows_blob_upload() {
|
||||
async fn test_atproto_scope_denies_blob_upload() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let (session, _mock) = create_user_and_oauth_session_with_scope(
|
||||
@@ -281,15 +235,13 @@ async fn test_atproto_scope_allows_blob_upload() {
|
||||
|
||||
assert_eq!(
|
||||
upload_res.status(),
|
||||
StatusCode::OK,
|
||||
"atproto scope should allow blob upload"
|
||||
StatusCode::FORBIDDEN,
|
||||
"atproto scope alone should deny blob upload"
|
||||
);
|
||||
let upload_body: Value = upload_res.json().await.unwrap();
|
||||
assert!(upload_body["blob"]["ref"]["$link"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_atproto_scope_allows_batch_writes() {
|
||||
async fn test_atproto_scope_denies_batch_writes() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let (session, _mock) = create_user_and_oauth_session_with_scope(
|
||||
@@ -316,16 +268,6 @@ async fn test_atproto_scope_allows_batch_writes() {
|
||||
"text": "Batch post 1",
|
||||
"createdAt": now
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": collection,
|
||||
"rkey": "batch-scope-2",
|
||||
"value": {
|
||||
"$type": collection,
|
||||
"text": "Batch post 2",
|
||||
"createdAt": now
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -335,8 +277,8 @@ async fn test_atproto_scope_allows_batch_writes() {
|
||||
|
||||
assert_eq!(
|
||||
apply_res.status(),
|
||||
StatusCode::OK,
|
||||
"atproto scope should allow batch writes"
|
||||
StatusCode::FORBIDDEN,
|
||||
"atproto scope alone should deny batch writes"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use tranquil_pds::delegation::{intersect_scopes, scopes::validate_delegation_scopes};
|
||||
use tranquil_pds::delegation::{ValidatedDelegationScope, intersect_scopes};
|
||||
use tranquil_pds::oauth::scopes::{
|
||||
AccountAction, IdentityAttr, ParsedScope, RepoAction, ScopePermissions, parse_scope,
|
||||
parse_scope_string,
|
||||
@@ -140,10 +140,10 @@ fn test_multiple_scopes_parsing() {
|
||||
#[test]
|
||||
fn test_permissions_null_scope_defaults_atproto() {
|
||||
let perms = ScopePermissions::from_scope_string(None);
|
||||
assert!(perms.has_full_access());
|
||||
assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
|
||||
assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
|
||||
assert!(perms.allows_repo(RepoAction::Delete, "any.collection"));
|
||||
assert!(!perms.has_full_access());
|
||||
assert!(!perms.allows_repo(RepoAction::Create, "any.collection"));
|
||||
assert!(!perms.allows_repo(RepoAction::Update, "any.collection"));
|
||||
assert!(!perms.allows_repo(RepoAction::Delete, "any.collection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,12 +177,11 @@ fn test_permissions_rpc_lxm_wildcard_prefix() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delegation_intersect_params_behavior() {
|
||||
fn test_delegation_intersect_mismatched_params_empty() {
|
||||
let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
|
||||
|
||||
assert!(
|
||||
result.is_empty() || result.contains("repo:*"),
|
||||
"Delegation intersection with different action params: '{}'",
|
||||
result.is_empty(),
|
||||
"Mismatched action params must produce empty intersection, got: '{}'",
|
||||
result
|
||||
);
|
||||
}
|
||||
@@ -190,36 +189,39 @@ fn test_delegation_intersect_params_behavior() {
|
||||
#[test]
|
||||
fn test_delegation_intersect_wildcard_vs_specific() {
|
||||
let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*");
|
||||
assert!(result.contains("repo:"));
|
||||
assert_eq!(
|
||||
result, "repo:app.bsky.feed.post?action=create",
|
||||
"Intersection must return the narrower requested scope, not the granted wildcard"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delegation_validate_known_prefixes() {
|
||||
assert!(validate_delegation_scopes("atproto").is_ok());
|
||||
assert!(validate_delegation_scopes("repo:*").is_ok());
|
||||
assert!(validate_delegation_scopes("blob:*/*").is_ok());
|
||||
assert!(validate_delegation_scopes("rpc:*").is_ok());
|
||||
assert!(validate_delegation_scopes("account:email").is_ok());
|
||||
assert!(validate_delegation_scopes("identity:handle").is_ok());
|
||||
assert!(validate_delegation_scopes("transition:generic").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("atproto").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("repo:*").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("blob:*/*").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("rpc:*").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("account:email").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("identity:handle").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("transition:generic").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delegation_validate_unknown_prefixes() {
|
||||
assert!(validate_delegation_scopes("invalid:scope").is_err());
|
||||
assert!(validate_delegation_scopes("custom:something").is_err());
|
||||
assert!(validate_delegation_scopes("made:up").is_err());
|
||||
assert!(ValidatedDelegationScope::new("invalid:scope").is_err());
|
||||
assert!(ValidatedDelegationScope::new("custom:something").is_err());
|
||||
assert!(ValidatedDelegationScope::new("made:up").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delegation_validate_empty() {
|
||||
assert!(validate_delegation_scopes("").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delegation_validate_multiple() {
|
||||
assert!(validate_delegation_scopes("atproto repo:* blob:*/*").is_ok());
|
||||
assert!(validate_delegation_scopes("atproto invalid:scope").is_err());
|
||||
assert!(ValidatedDelegationScope::new("atproto repo:* blob:*/*").is_ok());
|
||||
assert!(ValidatedDelegationScope::new("atproto invalid:scope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,15 +33,15 @@ pub struct ScopeDefinition {
|
||||
pub display_name: &'static str,
|
||||
}
|
||||
|
||||
pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> = LazyLock::new(
|
||||
|| {
|
||||
pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
|
||||
LazyLock::new(|| {
|
||||
let definitions = vec![
|
||||
ScopeDefinition {
|
||||
scope: "atproto",
|
||||
category: ScopeCategory::Core,
|
||||
required: true,
|
||||
description: "Full access to read, write, and manage this account (when no granular permissions are specified)",
|
||||
display_name: "Full Account Access",
|
||||
description: "Identity verification and session establishment",
|
||||
display_name: "AT Protocol Access",
|
||||
},
|
||||
ScopeDefinition {
|
||||
scope: "transition:generic",
|
||||
@@ -109,8 +109,7 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
|
||||
];
|
||||
|
||||
definitions.into_iter().map(|d| (d.scope, d)).collect()
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_scope_definition(scope: &str) -> Option<&'static ScopeDefinition> {
|
||||
|
||||
@@ -24,8 +24,7 @@ impl ScopePermissions {
|
||||
|
||||
let parsed = parse_scope_string(scope_str);
|
||||
|
||||
let has_atproto = parsed.iter().any(|p| matches!(p, ParsedScope::Atproto));
|
||||
let mut has_transition_generic = parsed
|
||||
let has_transition_generic = parsed
|
||||
.iter()
|
||||
.any(|p| matches!(p, ParsedScope::TransitionGeneric));
|
||||
let has_transition_chat = parsed
|
||||
@@ -35,21 +34,6 @@ impl ScopePermissions {
|
||||
.iter()
|
||||
.any(|p| matches!(p, ParsedScope::TransitionEmail));
|
||||
|
||||
let has_granular_scopes = parsed.iter().any(|p| {
|
||||
matches!(
|
||||
p,
|
||||
ParsedScope::Repo(_)
|
||||
| ParsedScope::Blob(_)
|
||||
| ParsedScope::Rpc(_)
|
||||
| ParsedScope::Account(_)
|
||||
| ParsedScope::Identity(_)
|
||||
)
|
||||
});
|
||||
|
||||
if has_atproto && !has_granular_scopes {
|
||||
has_transition_generic = true;
|
||||
}
|
||||
|
||||
Self {
|
||||
scopes,
|
||||
parsed,
|
||||
@@ -347,13 +331,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_atproto_scope_allows_everything() {
|
||||
fn test_atproto_scope_is_identity_only() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto"));
|
||||
assert!(perms.has_full_access());
|
||||
assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
|
||||
assert!(perms.allows_blob("image/png"));
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
|
||||
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage));
|
||||
assert!(!perms.has_full_access());
|
||||
assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
|
||||
assert!(!perms.allows_blob("image/png"));
|
||||
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
|
||||
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,7 +358,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_empty_scope_defaults_to_atproto() {
|
||||
let perms = ScopePermissions::from_scope_string(None);
|
||||
assert!(perms.has_full_access());
|
||||
assert!(perms.has_scope("atproto"));
|
||||
assert!(!perms.has_full_access());
|
||||
assert!(!perms.allows_repo(RepoAction::Create, "any.collection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -491,8 +477,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_scope_with_atproto() {
|
||||
fn test_identity_scope_with_atproto_alone() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto"));
|
||||
assert!(!perms.allows_identity(IdentityAttr::Handle));
|
||||
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transition_generic_grants_identity() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
|
||||
assert!(perms.allows_identity(IdentityAttr::Handle));
|
||||
assert!(perms.allows_identity(IdentityAttr::Wildcard));
|
||||
}
|
||||
@@ -517,14 +510,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atproto_alone_has_full_access() {
|
||||
fn test_atproto_alone_grants_nothing() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto"));
|
||||
assert!(perms.has_full_access());
|
||||
assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
|
||||
assert!(perms.allows_repo(RepoAction::Delete, "any.collection"));
|
||||
assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
|
||||
assert!(perms.allows_blob("image/png"));
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
|
||||
assert!(!perms.has_full_access());
|
||||
assert!(!perms.allows_repo(RepoAction::Create, "any.collection"));
|
||||
assert!(!perms.allows_repo(RepoAction::Delete, "any.collection"));
|
||||
assert!(!perms.allows_repo(RepoAction::Update, "any.collection"));
|
||||
assert!(!perms.allows_blob("image/png"));
|
||||
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -798,3 +798,38 @@ pub enum CommsType {
|
||||
PasskeyRecovery,
|
||||
MigrationVerification,
|
||||
}
|
||||
|
||||
pub mod did_doc {
|
||||
pub fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
|
||||
doc.get("service")
|
||||
.and_then(|s| s.as_array())
|
||||
.and_then(|services| {
|
||||
services.iter().find_map(|svc| {
|
||||
let id = svc.get("id").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let svc_type = svc.get("type").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
if (id == "#atproto_pds" || id.ends_with("#atproto_pds"))
|
||||
&& svc_type == "AtprotoPersonalDataServer"
|
||||
{
|
||||
svc.get("serviceEndpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_handle(doc: &serde_json::Value) -> Option<String> {
|
||||
doc.get("alsoKnownAs")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|aliases| {
|
||||
aliases.iter().find_map(|alias| {
|
||||
alias
|
||||
.as_str()
|
||||
.and_then(|s| s.strip_prefix("at://"))
|
||||
.map(|h| h.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
|
||||
interface Controller {
|
||||
did: Did
|
||||
handle: Handle
|
||||
handle?: Handle
|
||||
grantedScopes: ScopeSet
|
||||
grantedAt: string
|
||||
isActive: boolean
|
||||
isLocal: boolean
|
||||
}
|
||||
|
||||
interface ControlledAccount {
|
||||
@@ -48,10 +49,72 @@
|
||||
let canControlAccounts = $derived(!hasControllers)
|
||||
|
||||
let showAddController = $state(false)
|
||||
let addControllerDid = $state('')
|
||||
let addControllerIdentifier = $state('')
|
||||
let addControllerScopes = $state('atproto')
|
||||
let addingController = $state(false)
|
||||
let addControllerConfirmed = $state(false)
|
||||
let resolvedController = $state<{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean } | null>(null)
|
||||
let resolving = $state(false)
|
||||
let resolveError = $state('')
|
||||
|
||||
let typeaheadResults = $state<Array<{ did: string; handle: string; displayName?: string; avatar?: string }>>([])
|
||||
let typeaheadTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let showTypeahead = $state(false)
|
||||
|
||||
function onControllerInput(value: string) {
|
||||
addControllerIdentifier = value
|
||||
resolvedController = null
|
||||
resolveError = ''
|
||||
|
||||
if (typeaheadTimeout) clearTimeout(typeaheadTimeout)
|
||||
|
||||
const trimmed = value.trim().replace(/^@/, '')
|
||||
if (trimmed.startsWith('did:') || trimmed.length < 2) {
|
||||
typeaheadResults = []
|
||||
showTypeahead = false
|
||||
return
|
||||
}
|
||||
|
||||
typeaheadTimeout = setTimeout(async () => {
|
||||
const resp = await fetch(
|
||||
`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(trimmed)}&limit=5`
|
||||
)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
typeaheadResults = (data.actors ?? []).map((a: Record<string, unknown>) => ({
|
||||
did: a.did as string,
|
||||
handle: a.handle as string,
|
||||
displayName: a.displayName as string | undefined,
|
||||
avatar: a.avatar as string | undefined,
|
||||
}))
|
||||
showTypeahead = typeaheadResults.length > 0
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function selectTypeahead(actor: { did: string; handle: string }) {
|
||||
addControllerIdentifier = actor.handle
|
||||
showTypeahead = false
|
||||
typeaheadResults = []
|
||||
resolveControllerIdentifier()
|
||||
}
|
||||
|
||||
async function resolveControllerIdentifier() {
|
||||
const identifier = addControllerIdentifier.trim().replace(/^@/, '')
|
||||
if (!identifier) return
|
||||
|
||||
resolving = true
|
||||
resolveError = ''
|
||||
resolvedController = null
|
||||
|
||||
const result = await api.resolveController(identifier)
|
||||
if (result.ok) {
|
||||
resolvedController = result.value
|
||||
} else {
|
||||
resolveError = $_('delegation.controllerNotFound')
|
||||
}
|
||||
resolving = false
|
||||
}
|
||||
|
||||
let showCreateDelegated = $state(false)
|
||||
let newDelegatedHandle = $state('')
|
||||
@@ -77,7 +140,8 @@
|
||||
handle: c.handle,
|
||||
grantedScopes: c.grantedScopes,
|
||||
grantedAt: c.grantedAt,
|
||||
isActive: c.isActive
|
||||
isActive: c.isActive,
|
||||
isLocal: c.isLocal
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -107,17 +171,18 @@
|
||||
}
|
||||
|
||||
async function addController() {
|
||||
if (!addControllerDid.trim()) return
|
||||
if (!resolvedController) return
|
||||
addingController = true
|
||||
|
||||
const controllerDid = unsafeAsDid(addControllerDid.trim())
|
||||
const controllerDid = unsafeAsDid(resolvedController.did)
|
||||
const scopes = unsafeAsScopeSet(addControllerScopes)
|
||||
const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes)
|
||||
if (result.ok) {
|
||||
toast.success($_('delegation.controllerAdded'))
|
||||
addControllerDid = ''
|
||||
addControllerIdentifier = ''
|
||||
addControllerScopes = 'atproto'
|
||||
addControllerConfirmed = false
|
||||
resolvedController = null
|
||||
showAddController = false
|
||||
await loadControllers()
|
||||
}
|
||||
@@ -182,7 +247,7 @@
|
||||
<div class="item-card" class:inactive={!controller.isActive}>
|
||||
<div class="item-info">
|
||||
<div class="item-header">
|
||||
<span class="item-handle">@{controller.handle || controller.did}</span>
|
||||
<span class="item-handle">{controller.handle ? `@${controller.handle}` : controller.did}</span>
|
||||
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
|
||||
{#if !controller.isActive}
|
||||
<span class="badge inactive">{$_('delegation.inactive')}</span>
|
||||
@@ -227,15 +292,52 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
|
||||
<input
|
||||
id="controllerDid"
|
||||
type="text"
|
||||
bind:value={addControllerDid}
|
||||
placeholder="did:plc:..."
|
||||
disabled={addingController}
|
||||
/>
|
||||
<div class="field controller-search">
|
||||
<label for="controllerIdentifier">{$_('delegation.controllerIdentifier')}</label>
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
id="controllerIdentifier"
|
||||
type="text"
|
||||
value={addControllerIdentifier}
|
||||
oninput={(e) => onControllerInput((e.target as HTMLInputElement).value)}
|
||||
onblur={() => { setTimeout(() => { showTypeahead = false }, 200) }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); showTypeahead = false; resolveControllerIdentifier() } }}
|
||||
placeholder="handle or did:plc:..."
|
||||
disabled={addingController}
|
||||
/>
|
||||
{#if showTypeahead && typeaheadResults.length > 0}
|
||||
<div class="typeahead-dropdown">
|
||||
{#each typeaheadResults as actor}
|
||||
<button type="button" class="typeahead-item" onmousedown={() => selectTypeahead(actor)}>
|
||||
{#if actor.avatar}
|
||||
<img src={actor.avatar} alt="" class="typeahead-avatar" />
|
||||
{/if}
|
||||
<div class="typeahead-text">
|
||||
{#if actor.displayName}
|
||||
<span class="typeahead-name">{actor.displayName}</span>
|
||||
{/if}
|
||||
<span class="typeahead-handle">@{actor.handle}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if resolving}
|
||||
<span class="resolve-status">{$_('common.loading')}</span>
|
||||
{:else if resolveError}
|
||||
<span class="resolve-status error">{resolveError}</span>
|
||||
{:else if resolvedController}
|
||||
<div class="resolved-info">
|
||||
<span class="resolved-did">{resolvedController.did}</span>
|
||||
{#if resolvedController.handle}
|
||||
<span class="resolved-handle">@{resolvedController.handle}</span>
|
||||
{/if}
|
||||
{#if !resolvedController.isLocal && resolvedController.pdsUrl}
|
||||
<span class="badge external">{new URL(resolvedController.pdsUrl).hostname}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
|
||||
@@ -253,7 +355,7 @@
|
||||
<button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
|
||||
<button type="button" onclick={addController} disabled={addingController || !resolvedController || !addControllerConfirmed}>
|
||||
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -636,6 +738,114 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.controller-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.typeahead-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.typeahead-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.typeahead-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.typeahead-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.typeahead-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.typeahead-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.typeahead-handle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.resolve-status {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.resolve-status.error {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.resolved-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.resolved-did {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.resolved-handle {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.badge.external {
|
||||
background: var(--info-bg, var(--bg-tertiary));
|
||||
color: var(--info-text, var(--text-secondary));
|
||||
border: 1px solid var(--info-border, var(--border-color));
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.item-card {
|
||||
flex-direction: column;
|
||||
|
||||
+15
-1
@@ -326,7 +326,7 @@ function _castDelegationController(raw: unknown): DelegationController {
|
||||
const c = raw as Record<string, unknown>;
|
||||
return {
|
||||
did: unsafeAsDid(c.did as string),
|
||||
handle: unsafeAsHandle(c.handle as string),
|
||||
handle: c.handle ? unsafeAsHandle(c.handle as string) : undefined,
|
||||
grantedScopes: unsafeAsScopeSet(
|
||||
(c.granted_scopes ?? c.grantedScopes) as string,
|
||||
),
|
||||
@@ -334,6 +334,7 @@ function _castDelegationController(raw: unknown): DelegationController {
|
||||
(c.granted_at ?? c.grantedAt ?? c.added_at) as string,
|
||||
),
|
||||
isActive: (c.is_active ?? c.isActive ?? true) as boolean,
|
||||
isLocal: (c.is_local ?? c.isLocal ?? true) as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1471,6 +1472,19 @@ export const api = {
|
||||
return xrpcResult("_delegation.getScopePresets");
|
||||
},
|
||||
|
||||
resolveController(
|
||||
identifier: string,
|
||||
): Promise<
|
||||
Result<
|
||||
{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean },
|
||||
ApiError
|
||||
>
|
||||
> {
|
||||
return xrpcResult("_delegation.resolveController", {
|
||||
params: { identifier },
|
||||
});
|
||||
},
|
||||
|
||||
addDelegationController(
|
||||
token: AccessToken,
|
||||
controllerDid: Did,
|
||||
|
||||
@@ -570,10 +570,11 @@ export interface SsoLinkedAccount {
|
||||
|
||||
export interface DelegationController {
|
||||
did: Did;
|
||||
handle: Handle;
|
||||
handle?: Handle;
|
||||
grantedScopes: ScopeSet;
|
||||
grantedAt: ISODateString;
|
||||
isActive: boolean;
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
export interface DelegationControlledAccount {
|
||||
|
||||
@@ -569,9 +569,13 @@
|
||||
"required": "Required",
|
||||
"rememberChoiceLabel": "Remember my choice for this application",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol Access",
|
||||
"description": "Identity verification and session establishment"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol Access",
|
||||
"description": "AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
"description": "AT Protocol baseline (permissions determined by selected options below)"
|
||||
}
|
||||
},
|
||||
"unexpectedState": {
|
||||
@@ -818,6 +822,7 @@
|
||||
"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",
|
||||
"controllerIdentifier": "Controller handle or DID",
|
||||
"accessLevel": "Access Level",
|
||||
"adding": "Adding...",
|
||||
"addControllerButton": "+ Add Controller",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"required": "Vaaditaan",
|
||||
"rememberChoiceLabel": "Muista valintani tälle sovellukselle",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol -käyttöoikeus",
|
||||
"description": "Henkilöllisyyden varmennus ja istunnon muodostus"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol -käyttöoikeus",
|
||||
"description": "AT Protocol -peruslaajuus (oikeudet määräytyvät alla valittujen vaihtoehtojen mukaan)"
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"required": "必須",
|
||||
"rememberChoiceLabel": "このアプリに対する選択を記憶する",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol アクセス",
|
||||
"description": "本人確認とセッション確立"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol アクセス",
|
||||
"description": "AT Protocol 基本スコープ(権限は以下で選択したオプションによって決まります)"
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"required": "필수",
|
||||
"rememberChoiceLabel": "이 앱에 대한 선택 기억하기",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol 액세스",
|
||||
"description": "신원 확인 및 세션 설정"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol 액세스",
|
||||
"description": "AT Protocol 기본 범위 (권한은 아래 선택한 옵션에 의해 결정됨)"
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"required": "Krävs",
|
||||
"rememberChoiceLabel": "Kom ihåg mitt val för denna applikation",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol-åtkomst",
|
||||
"description": "Identitetsverifiering och sessionsupprättande"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol-åtkomst",
|
||||
"description": "AT Protocol basomfattning (behörigheter bestäms av valda alternativ nedan)"
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"required": "必需",
|
||||
"rememberChoiceLabel": "记住对此应用的授权选择",
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol 访问",
|
||||
"description": "身份验证和会话建立"
|
||||
},
|
||||
"atprotoWithGranular": {
|
||||
"name": "AT Protocol 访问",
|
||||
"description": "AT Protocol 基础范围(权限由下方选择的选项决定)"
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { navigate, routes } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
prepareRequestOptions,
|
||||
serializeAssertionResponse,
|
||||
type WebAuthnRequestOptionsResponse,
|
||||
} from '../lib/webauthn'
|
||||
|
||||
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.search)
|
||||
@@ -50,18 +33,12 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`)
|
||||
const response = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`)
|
||||
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
|
||||
}
|
||||
delegatedHandle = delegatedDid
|
||||
}
|
||||
} catch {
|
||||
delegatedHandle = delegatedDid
|
||||
@@ -70,7 +47,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIdentifierSubmit(e: Event) {
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!controllerIdentifier.trim()) return
|
||||
|
||||
@@ -91,128 +68,13 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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(/^@/, ''),
|
||||
delegated_did: delegatedDid
|
||||
})
|
||||
})
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const data = await startResponse.json()
|
||||
error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart')
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri || !delegatedDid) {
|
||||
error = $_('oauthDelegation.missingInfo')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
const { options } = await startResponse.json()
|
||||
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
}) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
error = $_('oauthDelegation.passkeyCancelled')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
const credentialData = serializeAssertionResponse(credential)
|
||||
|
||||
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(routes.oauthTotp, { params: { request_uri: requestUri } })
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_2fa) {
|
||||
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: 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: {
|
||||
@@ -222,30 +84,19 @@
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
delegated_did: delegatedDid,
|
||||
controller_did: controllerDid,
|
||||
password,
|
||||
remember_device: rememberDevice
|
||||
controller_did: resolvedDid,
|
||||
auth_method: 'cross_pds'
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || data.success === false || data.error) {
|
||||
error = data.error_description || data.error || $_('oauthDelegation.authFailed')
|
||||
error = data.error || $_('oauthDelegation.authFailed')
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_totp) {
|
||||
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
|
||||
return
|
||||
}
|
||||
|
||||
if (data.needs_2fa) {
|
||||
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
@@ -254,7 +105,8 @@
|
||||
error = $_('oauthDelegation.unexpectedResponse')
|
||||
submitting = false
|
||||
} catch {
|
||||
error = $_('oauthDelegation.authFailed')
|
||||
error = $_('oauthDelegation.controllerNotFound')
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
@@ -285,12 +137,6 @@
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = 'identifier'
|
||||
password = ''
|
||||
error = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="delegation-container">
|
||||
@@ -298,7 +144,7 @@
|
||||
<div class="loading">
|
||||
<p>{$_('oauthDelegation.loading')}</p>
|
||||
</div>
|
||||
{:else if step === 'identifier'}
|
||||
{:else}
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauthDelegation.title')}</h1>
|
||||
<p class="subtitle">
|
||||
@@ -311,7 +157,7 @@
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleIdentifierSubmit}>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label>
|
||||
<input
|
||||
@@ -334,109 +180,6 @@
|
||||
</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>
|
||||
|
||||
@@ -469,111 +212,12 @@
|
||||
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;
|
||||
@@ -585,7 +229,6 @@
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -600,20 +243,6 @@
|
||||
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);
|
||||
@@ -664,40 +293,4 @@
|
||||
.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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE account_delegations DROP CONSTRAINT account_delegations_controller_did_fkey;
|
||||
ALTER TABLE account_delegations DROP CONSTRAINT account_delegations_granted_by_fkey;
|
||||
ALTER TABLE app_passwords DROP CONSTRAINT app_passwords_created_by_controller_did_fkey;
|
||||
Reference in New Issue
Block a user