feat: cross-pds delegation

This commit is contained in:
Lewis
2026-03-17 19:22:34 +00:00
committed by Tangled
parent d5ed420dd7
commit 7926c798c6
50 changed files with 2077 additions and 1701 deletions
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ./
+115 -246
View File
@@ -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())
}
+28 -142
View File
@@ -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,
};
+1 -1
View File
@@ -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 =
+1
View File
@@ -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 -2
View File
@@ -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()],
})
}
+1
View File
@@ -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,
};
+1 -1
View File
@@ -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,
};
+7 -15
View File
@@ -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>;
}
+13 -109
View File
@@ -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(&params.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 &params.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,
&params.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()
+8
View File
@@ -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,
};
+81
View File
@@ -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::*;
+3 -1
View File
@@ -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::{
+25
View File
@@ -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,
+1 -1
View File
@@ -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())
}
+42 -3
View File
@@ -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,
})
}
+51 -75
View File
@@ -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(),
)
}
}
}
+141 -32
View File
@@ -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
+415
View File
@@ -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, &params, 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, &params, 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()
}
}
+2 -1
View File
@@ -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};
+9
View File
@@ -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,
+16 -3
View File
@@ -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()
+4 -4
View File
@@ -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();
+11 -69
View File
@@ -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"
);
}
+25 -23
View File
@@ -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]
+5 -6
View File
@@ -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> {
+25 -32
View File
@@ -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]
+35
View File
@@ -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
View File
@@ -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,
+2 -1
View File
@@ -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 {
+6 -1
View File
@@ -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",
+4
View File
@@ -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)"
+4
View File
@@ -575,6 +575,10 @@
"required": "必須",
"rememberChoiceLabel": "このアプリに対する選択を記憶する",
"scopes": {
"atproto": {
"name": "AT Protocol アクセス",
"description": "本人確認とセッション確立"
},
"atprotoWithGranular": {
"name": "AT Protocol アクセス",
"description": "AT Protocol 基本スコープ(権限は以下で選択したオプションによって決まります)"
+4
View File
@@ -575,6 +575,10 @@
"required": "필수",
"rememberChoiceLabel": "이 앱에 대한 선택 기억하기",
"scopes": {
"atproto": {
"name": "AT Protocol 액세스",
"description": "신원 확인 및 세션 설정"
},
"atprotoWithGranular": {
"name": "AT Protocol 액세스",
"description": "AT Protocol 기본 범위 (권한은 아래 선택한 옵션에 의해 결정됨)"
+4
View File
@@ -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)"
+4
View File
@@ -575,6 +575,10 @@
"required": "必需",
"rememberChoiceLabel": "记住对此应用的授权选择",
"scopes": {
"atproto": {
"name": "AT Protocol 访问",
"description": "身份验证和会话建立"
},
"atprotoWithGranular": {
"name": "AT Protocol 访问",
"description": "AT Protocol 基础范围(权限由下方选择的选项决定)"
+13 -420
View File
@@ -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}>
&larr; {$_('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;