From 7926c798c663ecc7d3629c99c77351fb2cbbf5da Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 16 Mar 2026 20:24:33 +0200 Subject: [PATCH] feat: cross-pds delegation --- ...dd880225931f29ce4d8c5184197fecce25fa7.json | 52 ++ ...7736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json | 52 ++ ...2f3cdeb92e2b0a72405ea0441b0340b177b96.json | 22 + Cargo.lock | 40 +- Cargo.toml | 2 +- Dockerfile | 3 +- crates/tranquil-api/src/delegation.rs | 361 +++---- crates/tranquil-api/src/identity/account.rs | 170 +--- crates/tranquil-api/src/identity/did.rs | 2 +- crates/tranquil-api/src/identity/mod.rs | 1 + crates/tranquil-api/src/identity/plc/sign.rs | 4 +- .../tranquil-api/src/identity/plc/submit.rs | 4 +- crates/tranquil-api/src/identity/provision.rs | 117 +++ crates/tranquil-api/src/lib.rs | 1 + .../src/server/passkey_account.rs | 30 +- crates/tranquil-api/src/server/session.rs | 2 +- crates/tranquil-db-traits/src/delegation.rs | 22 +- crates/tranquil-db/src/postgres/delegation.rs | 122 +-- .../src/endpoints/delegation.rs | 878 +++++++++--------- .../src/endpoints/token/helpers.rs | 7 +- crates/tranquil-oauth-server/src/lib.rs | 8 + .../src/sso_endpoints.rs | 11 +- crates/tranquil-oauth/src/dpop.rs | 81 ++ crates/tranquil-oauth/src/lib.rs | 4 +- crates/tranquil-pds/src/api/validation.rs | 25 + crates/tranquil-pds/src/auth/mod.rs | 2 +- crates/tranquil-pds/src/delegation/mod.rs | 45 +- crates/tranquil-pds/src/delegation/roles.rs | 126 +-- crates/tranquil-pds/src/delegation/scopes.rs | 173 +++- crates/tranquil-pds/src/oauth/client.rs | 415 +++++++++ crates/tranquil-pds/src/oauth/mod.rs | 3 +- crates/tranquil-pds/src/state.rs | 9 + crates/tranquil-pds/src/util.rs | 19 +- crates/tranquil-pds/tests/oauth_lifecycle.rs | 8 +- crates/tranquil-pds/tests/oauth_scopes.rs | 80 +- crates/tranquil-pds/tests/scope_edge_cases.rs | 48 +- crates/tranquil-scopes/src/definitions.rs | 11 +- crates/tranquil-scopes/src/permissions.rs | 57 +- crates/tranquil-types/src/lib.rs | 35 + .../dashboard/ControllersContent.svelte | 244 ++++- frontend/src/lib/api.ts | 16 +- frontend/src/lib/types/api.ts | 3 +- frontend/src/locales/en.json | 7 +- frontend/src/locales/fi.json | 4 + frontend/src/locales/ja.json | 4 + frontend/src/locales/ko.json | 4 + frontend/src/locales/sv.json | 4 + frontend/src/locales/zh.json | 4 + frontend/src/routes/OAuthDelegation.svelte | 433 +-------- migrations/20260316_cross_pds_delegation.sql | 3 + 50 files changed, 2077 insertions(+), 1701 deletions(-) create mode 100644 .sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json create mode 100644 .sqlx/query-dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json create mode 100644 .sqlx/query-ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96.json create mode 100644 crates/tranquil-api/src/identity/provision.rs create mode 100644 crates/tranquil-pds/src/oauth/client.rs create mode 100644 migrations/20260316_cross_pds_delegation.sql diff --git a/.sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json b/.sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json new file mode 100644 index 0000000..d2f730f --- /dev/null +++ b/.sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json @@ -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" +} diff --git a/.sqlx/query-dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json b/.sqlx/query-dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json new file mode 100644 index 0000000..333bfac --- /dev/null +++ b/.sqlx/query-dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json @@ -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" +} diff --git a/.sqlx/query-ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96.json b/.sqlx/query-ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96.json new file mode 100644 index 0000000..4efeaa6 --- /dev/null +++ b/.sqlx/query-ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96.json @@ -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" +} diff --git a/Cargo.lock b/Cargo.lock index 8e48041..d33da32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 22f8145..02ae042 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ ] [workspace.package] -version = "0.4.2" +version = "0.4.3" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/Dockerfile b/Dockerfile index 34ea471..f6e4479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/crates/tranquil-api/src/delegation.rs b/crates/tranquil-api/src/delegation.rs index 77e1e92..7fbd8c5 100644 --- a/crates/tranquil-api/src/delegation.rs +++ b/crates/tranquil-api/src/delegation.rs @@ -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, - pub is_active: bool, -} - -#[derive(Debug, Serialize)] -pub struct ListControllersResponse { - pub controllers: Vec, -} - pub async fn list_controllers( State(state): State, auth: Auth, @@ -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, Json(input): Json, ) -> Result { - 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, -} - -#[derive(Debug, Serialize)] -pub struct ListControlledAccountsResponse { - pub accounts: Vec, -} - pub async fn list_controlled_accounts( State(state): State, auth: Auth, @@ -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, - pub action_type: String, - pub action_details: Option, - pub created_at: chrono::DateTime, -} - -#[derive(Debug, Serialize)] -pub struct GetAuditLogResponse { - pub entries: Vec, - pub total: i64, -} - pub async fn get_audit_log( State(state): State, auth: Auth, @@ -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, + 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, + Query(params): Query, +) -> Result { + 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 = 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()) +} + diff --git a/crates/tranquil-api/src/identity/account.rs b/crates/tranquil-api/src/identity/account.rs index 5043cb9..5b62352 100644 --- a/crates/tranquil-api/src/identity/account.rs +++ b/crates/tranquil-api/src/identity/account.rs @@ -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 = 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, Option) = 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, }; diff --git a/crates/tranquil-api/src/identity/did.rs b/crates/tranquil-api/src/identity/did.rs index 032dc99..6c52e27 100644 --- a/crates/tranquil-api/src/identity/did.rs +++ b/crates/tranquil-api/src/identity/did.rs @@ -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 = diff --git a/crates/tranquil-api/src/identity/mod.rs b/crates/tranquil-api/src/identity/mod.rs index 9e01cfc..38ed207 100644 --- a/crates/tranquil-api/src/identity/mod.rs +++ b/crates/tranquil-api/src/identity/mod.rs @@ -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::{ diff --git a/crates/tranquil-api/src/identity/plc/sign.rs b/crates/tranquil-api/src/identity/plc/sign.rs index d98dfd5..6f8d602 100644 --- a/crates/tranquil-api/src/identity/plc/sign.rs +++ b/crates/tranquil-api/src/identity/plc/sign.rs @@ -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 diff --git a/crates/tranquil-api/src/identity/plc/submit.rs b/crates/tranquil-api/src/identity/plc/submit.rs index 445c642..a863a5b 100644 --- a/crates/tranquil-api/src/identity/plc/submit.rs +++ b/crates/tranquil-api/src/identity/plc/submit.rs @@ -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 { diff --git a/crates/tranquil-api/src/identity/provision.rs b/crates/tranquil-api/src/identity/provision.rs new file mode 100644 index 0000000..2e43136 --- /dev/null +++ b/crates/tranquil-api/src/identity/provision.rs @@ -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, + pub signing_key: SigningKey, +} + +pub async fn create_plc_did(state: &AppState, handle: &str) -> Result { + 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 { + 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, + pub commit_cid: cid::Cid, + pub mst_root_cid: cid::Cid, + pub repo_rev: String, + pub genesis_block_cids: Vec>, +} + +pub async fn init_genesis_repo( + state: &AppState, + did: &Did, + signing_key: &SigningKey, + signing_key_bytes: &[u8], +) -> Result { + 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()], + }) +} diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index dc778e5..05269ab 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -228,6 +228,7 @@ pub fn api_routes() -> axum::Router { .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)) diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index f5acce3..0513cc5 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -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, }; diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index f7f564b..f5fa84c 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -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, }; diff --git a/crates/tranquil-db-traits/src/delegation.rs b/crates/tranquil-db-traits/src/delegation.rs index 56a36b9..1990292 100644 --- a/crates/tranquil-db-traits/src/delegation.rs +++ b/crates/tranquil-db-traits/src/delegation.rs @@ -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, pub granted_scopes: DbScope, pub granted_at: DateTime, 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, pub action_type: DelegationActionType, pub action_details: Option, + #[serde(skip_serializing)] pub ip_address: Option, + #[serde(skip_serializing)] pub user_agent: Option, pub created_at: DateTime, } @@ -102,15 +108,8 @@ pub trait DelegationRepository: Send + Sync { controller_did: &Did, ) -> Result, DbError>; - async fn get_active_controllers_for_account( - &self, - delegated_did: &Did, - ) -> Result, DbError>; - async fn count_active_controllers(&self, delegated_did: &Did) -> Result; - async fn has_any_controllers(&self, did: &Did) -> Result; - async fn controls_any_accounts(&self, did: &Did) -> Result; #[allow(clippy::too_many_arguments)] @@ -132,12 +131,5 @@ pub trait DelegationRepository: Send + Sync { offset: i64, ) -> Result, DbError>; - async fn get_audit_log_by_controller( - &self, - controller_did: &Did, - limit: i64, - offset: i64, - ) -> Result, DbError>; - async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result; } diff --git a/crates/tranquil-db/src/postgres/delegation.rs b/crates/tranquil-db/src/postgres/delegation.rs index 659bc9c..bd2fe3b 100644 --- a/crates/tranquil-db/src/postgres/delegation.rs +++ b/crates/tranquil-db/src/postgres/delegation.rs @@ -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, 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 { 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 { - 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 { 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, 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 { let count = sqlx::query_scalar!( r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#, diff --git a/crates/tranquil-oauth-server/src/endpoints/delegation.rs b/crates/tranquil-oauth-server/src/endpoints/delegation.rs index 7f4e07d..958fa03 100644 --- a/crates/tranquil-oauth-server/src/endpoints/delegation.rs +++ b/crates/tranquil-oauth-server/src/endpoints/delegation.rs @@ -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 { + s.parse() + .map_err(|_| DelegationAuthResponse::err(format!("Invalid {} DID", label))) +} + +async fn get_auth_request(state: &AppState, request_uri: &str) -> Result { + 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 { + 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, pub controller_did: String, - pub password: PlainPassword, + pub password: Option, #[serde(default)] pub remember_device: bool, + pub auth_method: Option, } -#[derive(Debug, Serialize)] -pub struct DelegationAuthResponse { - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub needs_totp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub redirect_uri: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, +enum DelegationAuthResponse { + Redirect(String), + NeedsTotp(String), + Error(String), + TotpError(String), +} + +impl DelegationAuthResponse { + fn err(msg: impl Into) -> Response { + Self::Error(msg.into()).into_response() + } + + fn redirect(uri: impl Into) -> Response { + Self::Redirect(uri.into()).into_response() + } + + fn needs_totp(uri: impl Into) -> Response { + Self::NeedsTotp(uri.into()).into_response() + } + + fn totp_error(msg: impl Into) -> 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, + #[serde(skip_serializing_if = "Option::is_none")] + redirect_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + } + + 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, ) -> 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, ) -> 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, +} + +pub async fn delegation_callback( + State(state): State, + _rate_limit: OAuthRateLimited, + Query(params): Query, +) -> Response { + let auth_state = match state + .cross_pds_oauth + .retrieve_auth_state(¶ms.state) + .await + { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to retrieve cross-PDS auth state: {:?}", e); + return ( + axum::http::StatusCode::BAD_REQUEST, + "Cross-PDS auth state expired or invalid", + ) + .into_response(); + } + }; + + if let Some(ref expected_issuer) = auth_state.expected_issuer { + match ¶ms.iss { + Some(iss) if iss != expected_issuer => { + tracing::error!( + "Cross-PDS issuer mismatch: expected {}, got {}", + expected_issuer, + iss + ); + return ( + axum::http::StatusCode::FORBIDDEN, + "Authorization server issuer mismatch", + ) + .into_response(); + } + None => { + tracing::error!( + "Cross-PDS callback missing iss parameter (expected {}), possible mix-up attack", + expected_issuer + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + "Missing required iss parameter", + ) + .into_response(); + } + _ => {} + } + } + + let hostname = &tranquil_config::get().server.hostname; + let urls = delegation_oauth_urls(hostname); + + let returned_sub = match state + .cross_pds_oauth + .exchange_code( + &auth_state, + ¶ms.code, + &urls.client_id, + &urls.redirect_uri, + ) + .await + { + Ok(sub) => sub, + Err(e) => { + tracing::error!("Cross-PDS token exchange failed: {:?}", e); + return ( + axum::http::StatusCode::BAD_GATEWAY, + "Controller authentication failed", + ) + .into_response(); + } + }; + + if returned_sub != auth_state.controller_did.as_str() { + tracing::error!( + "Cross-PDS DID mismatch: expected {}, got {}", + auth_state.controller_did, + returned_sub + ); + return (axum::http::StatusCode::FORBIDDEN, "Controller DID mismatch").into_response(); + } + + let delegated_did = &auth_state.delegated_did; + let controller_did = &auth_state.controller_did; + + if let Err(_) = get_delegation_grant(&state, delegated_did, controller_did).await { + tracing::warn!( + "Delegation grant revoked during cross-PDS auth: {} -> {}", + controller_did, + delegated_did + ); + return ( + axum::http::StatusCode::FORBIDDEN, + "Delegation grant has been revoked", + ) + .into_response(); + } + + if let Err(resp) = bind_delegation_to_request( + &state, + &auth_state.original_request_uri, + delegated_did, + controller_did, + ) + .await + { + return resp; + } let _ = state .delegation_repo .log_delegation_action( - &delegated_did, + delegated_did, controller_did, Some(controller_did), DelegationActionType::TokenIssued, Some(serde_json::json!({ - "client_id": request.client_id, - "granted_scopes": grant.granted_scopes, - "auth_method": "token" + "auth_method": "cross_pds", + "controller_pds": auth_state.controller_pds_url })), - Some(&ip), - user_agent.as_deref(), + None, + None, ) .await; - Json(DelegationAuthResponse { - success: true, - needs_totp: None, - redirect_uri: Some(format!( - "/app/oauth/consent?request_uri={}", - urlencoding::encode(&form.request_uri) - )), - error: None, - }) - .into_response() + Redirect::temporary(&consent_url(&auth_state.original_request_uri)).into_response() +} + +pub async fn delegation_client_metadata(State(_state): State) -> Response { + let hostname = &tranquil_config::get().server.hostname; + let metadata = build_client_metadata(hostname); + Json(metadata).into_response() } diff --git a/crates/tranquil-oauth-server/src/endpoints/token/helpers.rs b/crates/tranquil-oauth-server/src/endpoints/token/helpers.rs index e80fb63..91e811f 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/helpers.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/helpers.rs @@ -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() diff --git a/crates/tranquil-oauth-server/src/lib.rs b/crates/tranquil-oauth-server/src/lib.rs index b7b52b4..4d85a31 100644 --- a/crates/tranquil-oauth-server/src/lib.rs +++ b/crates/tranquil-oauth-server/src/lib.rs @@ -65,6 +65,14 @@ pub fn oauth_routes() -> axum::Router { "/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)) diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 4b1d3ef..7d0ebb0 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -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, }; diff --git a/crates/tranquil-oauth/src/dpop.rs b/crates/tranquil-oauth/src/dpop.rs index 8610d56..d80a489 100644 --- a/crates/tranquil-oauth/src/dpop.rs +++ b/crates/tranquil-oauth/src/dpop.rs @@ -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 { + 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 { + 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 { + let jwk = es256_signing_key_to_jwk(signing_key)?; + compute_jwk_thumbprint(&jwk) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tranquil-oauth/src/lib.rs b/crates/tranquil-oauth/src/lib.rs index 9785fbf..d12e1f1 100644 --- a/crates/tranquil-oauth/src/lib.rs +++ b/crates/tranquil-oauth/src/lib.rs @@ -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::{ diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index d6f085d..6eb31a9 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -308,6 +308,31 @@ pub fn validate_short_handle(handle: &str) -> Result Result { + 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, diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index e517578..84b27f8 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -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()) } diff --git a/crates/tranquil-pds/src/delegation/mod.rs b/crates/tranquil-pds/src/delegation/mod.rs index 1b3c520..43b21b6 100644 --- a/crates/tranquil-pds/src/delegation/mod.rs +++ b/crates/tranquil-pds/src/delegation/mod.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub pds_url: Option, + pub is_local: bool, +} + +pub async fn resolve_identity(state: &AppState, did: &Did) -> Option { + 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, + }) +} diff --git a/crates/tranquil-pds/src/delegation/roles.rs b/crates/tranquil-pds/src/delegation/roles.rs index aa6ff1b..7e91e8b 100644 --- a/crates/tranquil-pds/src/delegation/roles.rs +++ b/crates/tranquil-pds/src/delegation/roles.rs @@ -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, } -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, 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 { + 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, 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, 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, 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(), - ) - } - } -} diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 41236b0..2d223de 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -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::>().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::>().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 diff --git a/crates/tranquil-pds/src/oauth/client.rs b/crates/tranquil-pds/src/oauth/client.rs new file mode 100644 index 0000000..8a09d38 --- /dev/null +++ b/crates/tranquil-pds/src/oauth/client.rs @@ -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, +} + +#[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, +} + +impl CrossPdsOAuthClient { + pub fn new(cache: Arc) -> 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 { + 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 { + 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, + } + resp.json::() + .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 { + 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 { + 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>, + } + if let Ok(pr) = resp.json::().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 { + let cache_key = format!("cross_pds_oauth_meta:{}", pds_url); + if let Some(cached) = self.cache.get(&cache_key).await + && let Ok(meta) = serde_json::from_str(&cached) + { + return Ok(meta); + } + + let auth_server = self.resolve_authorization_server(pds_url).await?; + + let url = format!("{}/.well-known/oauth-authorization-server", auth_server); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?; + + if !resp.status().is_success() { + return Err(CrossPdsError::MetadataFetch(format!( + "HTTP {} from {}", + resp.status(), + url + ))); + } + + let meta: AuthorizationServerMetadata = resp + .json() + .await + .map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?; + + if let Ok(json_str) = serde_json::to_string(&meta) { + let _ = self + .cache + .set(&cache_key, &json_str, Duration::from_secs(300)) + .await; + } + + Ok(meta) + } + + pub async fn initiate_par( + &self, + pds_url: &str, + urls: &DelegationOAuthUrls, + login_hint: Option<&str>, + original_request_uri: &str, + controller_did: &Did, + delegated_did: &Did, + ) -> Result<(ParResult, CrossPdsAuthState, String), CrossPdsError> { + let meta = self.fetch_server_metadata(pds_url).await?; + let par_endpoint = meta + .pushed_authorization_request_endpoint + .as_deref() + .ok_or(CrossPdsError::NoParEndpoint)?; + + let code_verifier = crate::util::generate_random_token(); + let code_challenge = compute_pkce_challenge(&code_verifier); + let state = crate::util::generate_random_token(); + + let signing_key = SigningKey::random(&mut OsRng); + let dpop_key_der = URL_SAFE_NO_PAD.encode(signing_key.to_bytes()); + + let dpop_jkt = compute_es256_jkt(&signing_key) + .map_err(|e| CrossPdsError::ParFailed(format!("{:?}", e)))?; + + let mut params = vec![ + ("response_type", "code".to_string()), + ("client_id", urls.client_id.clone()), + ("redirect_uri", urls.redirect_uri.clone()), + ("scope", "atproto".to_string()), + ("state", state.clone()), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256".to_string()), + ("dpop_jkt", dpop_jkt), + ]; + if let Some(hint) = login_hint { + params.push(("login_hint", hint.to_string())); + } + + let resp = self + .send_with_dpop_retry(&signing_key, "POST", par_endpoint, ¶ms, None) + .await + .map_err(|e| CrossPdsError::ParFailed(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(CrossPdsError::ParFailed(format!("PAR rejected: {}", body))); + } + + #[derive(Deserialize)] + struct ParResp { + request_uri: String, + } + + let par_resp: ParResp = resp + .json() + .await + .map_err(|e| CrossPdsError::ParFailed(e.to_string()))?; + + let authorize_url = format!( + "{}?request_uri={}&client_id={}", + meta.authorization_endpoint, + urlencoding::encode(&par_resp.request_uri), + urlencoding::encode(&urls.client_id) + ); + + let auth_state = CrossPdsAuthState { + original_request_uri: original_request_uri.to_string(), + controller_did: controller_did.clone(), + controller_pds_url: pds_url.to_string(), + code_verifier, + dpop_private_key_der: dpop_key_der, + delegated_did: delegated_did.clone(), + expected_issuer: Some(meta.issuer.clone()), + }; + + Ok(( + ParResult { + request_uri: par_resp.request_uri, + authorize_url, + }, + auth_state, + state, + )) + } + + pub async fn exchange_code( + &self, + auth_state: &CrossPdsAuthState, + code: &str, + client_id: &str, + redirect_uri: &str, + ) -> Result { + let meta = self + .fetch_server_metadata(&auth_state.controller_pds_url) + .await?; + + let key_bytes = URL_SAFE_NO_PAD + .decode(&auth_state.dpop_private_key_der) + .map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?; + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()) + .map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?; + + let params = vec![ + ("grant_type", "authorization_code".to_string()), + ("code", code.to_string()), + ("redirect_uri", redirect_uri.to_string()), + ("code_verifier", auth_state.code_verifier.clone()), + ("client_id", client_id.to_string()), + ]; + + let resp = self + .send_with_dpop_retry(&signing_key, "POST", &meta.token_endpoint, ¶ms, None) + .await + .map_err(CrossPdsError::TokenExchangeFailed)?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(CrossPdsError::TokenExchangeFailed(format!( + "Token exchange rejected: {}", + body + ))); + } + + #[derive(Deserialize)] + struct TokenResp { + sub: Option, + token_type: Option, + error: Option, + error_description: Option, + } + + 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() + } +} diff --git a/crates/tranquil-pds/src/oauth/mod.rs b/crates/tranquil-pds/src/oauth/mod.rs index faa620f..68acb96 100644 --- a/crates/tranquil-pds/src/oauth/mod.rs +++ b/crates/tranquil-pds/src/oauth/mod.rs @@ -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}; diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index da6a92f..85a47b8 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -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, pub sso_manager: SsoManager, pub webauthn_config: Arc, + pub cross_pds_oauth: Arc, pub shutdown: CancellationToken, pub bootstrap_invite_code: Option, } @@ -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> { 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, diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index c2cb32e..9b004d1 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -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 { + 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) -> 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 = obj .iter() diff --git a/crates/tranquil-pds/tests/oauth_lifecycle.rs b/crates/tranquil-pds/tests/oauth_lifecycle.rs index 5a41f04..92c987b 100644 --- a/crates/tranquil-pds/tests/oauth_lifecycle.rs +++ b/crates/tranquil-pds/tests/oauth_lifecycle.rs @@ -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(); diff --git a/crates/tranquil-pds/tests/oauth_scopes.rs b/crates/tranquil-pds/tests/oauth_scopes.rs index 5e4cac3..c671142 100644 --- a/crates/tranquil-pds/tests/oauth_scopes.rs +++ b/crates/tranquil-pds/tests/oauth_scopes.rs @@ -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::>(), "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" ); } diff --git a/crates/tranquil-pds/tests/scope_edge_cases.rs b/crates/tranquil-pds/tests/scope_edge_cases.rs index 2f77c2b..51744b8 100644 --- a/crates/tranquil-pds/tests/scope_edge_cases.rs +++ b/crates/tranquil-pds/tests/scope_edge_cases.rs @@ -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] diff --git a/crates/tranquil-scopes/src/definitions.rs b/crates/tranquil-scopes/src/definitions.rs index d38a4b8..3265d74 100644 --- a/crates/tranquil-scopes/src/definitions.rs +++ b/crates/tranquil-scopes/src/definitions.rs @@ -33,15 +33,15 @@ pub struct ScopeDefinition { pub display_name: &'static str, } -pub static SCOPE_DEFINITIONS: LazyLock> = LazyLock::new( - || { +pub static SCOPE_DEFINITIONS: LazyLock> = + 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> = ]; definitions.into_iter().map(|d| (d.scope, d)).collect() - }, -); + }); #[allow(dead_code)] pub fn get_scope_definition(scope: &str) -> Option<&'static ScopeDefinition> { diff --git a/crates/tranquil-scopes/src/permissions.rs b/crates/tranquil-scopes/src/permissions.rs index 8a9f88c..66029ea 100644 --- a/crates/tranquil-scopes/src/permissions.rs +++ b/crates/tranquil-scopes/src/permissions.rs @@ -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] diff --git a/crates/tranquil-types/src/lib.rs b/crates/tranquil-types/src/lib.rs index fc1e92a..a3da867 100644 --- a/crates/tranquil-types/src/lib.rs +++ b/crates/tranquil-types/src/lib.rs @@ -798,3 +798,38 @@ pub enum CommsType { PasskeyRecovery, MigrationVerification, } + +pub mod did_doc { + pub fn extract_pds_endpoint(doc: &serde_json::Value) -> Option { + 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 { + 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()) + }) + }) + } +} diff --git a/frontend/src/components/dashboard/ControllersContent.svelte b/frontend/src/components/dashboard/ControllersContent.svelte index c65b6e6..23961e0 100644 --- a/frontend/src/components/dashboard/ControllersContent.svelte +++ b/frontend/src/components/dashboard/ControllersContent.svelte @@ -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>([]) + let typeaheadTimeout: ReturnType | 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) => ({ + 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 @@
- @{controller.handle || controller.did} + {controller.handle ? `@${controller.handle}` : controller.did} {getScopeLabel(controller.grantedScopes)} {#if !controller.isActive} {$_('delegation.inactive')} @@ -227,15 +292,52 @@
-
- - +
@@ -253,7 +355,7 @@ -
@@ -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; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1b00b92..f448e60 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -326,7 +326,7 @@ function _castDelegationController(raw: unknown): DelegationController { const c = raw as Record; 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, diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 42461e3..6fed80f 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -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 { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index fd6731d..0d0cfbb 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index d01c9bb..5f40173 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -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)" diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index e22cd35..87b96e3 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -575,6 +575,10 @@ "required": "必須", "rememberChoiceLabel": "このアプリに対する選択を記憶する", "scopes": { + "atproto": { + "name": "AT Protocol アクセス", + "description": "本人確認とセッション確立" + }, "atprotoWithGranular": { "name": "AT Protocol アクセス", "description": "AT Protocol 基本スコープ(権限は以下で選択したオプションによって決まります)" diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index edcadde..e59e0b4 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -575,6 +575,10 @@ "required": "필수", "rememberChoiceLabel": "이 앱에 대한 선택 기억하기", "scopes": { + "atproto": { + "name": "AT Protocol 액세스", + "description": "신원 확인 및 세션 설정" + }, "atprotoWithGranular": { "name": "AT Protocol 액세스", "description": "AT Protocol 기본 범위 (권한은 아래 선택한 옵션에 의해 결정됨)" diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index e3d346d..9cde91a 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -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)" diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index ac7f2ac..06a3a96 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -575,6 +575,10 @@ "required": "必需", "rememberChoiceLabel": "记住对此应用的授权选择", "scopes": { + "atproto": { + "name": "AT Protocol 访问", + "description": "身份验证和会话建立" + }, "atprotoWithGranular": { "name": "AT Protocol 访问", "description": "AT Protocol 基础范围(权限由下方选择的选项决定)" diff --git a/frontend/src/routes/OAuthDelegation.svelte b/frontend/src/routes/OAuthDelegation.svelte index 4557bfa..c00e1f8 100644 --- a/frontend/src/routes/OAuthDelegation.svelte +++ b/frontend/src/routes/OAuthDelegation.svelte @@ -1,29 +1,12 @@
@@ -298,7 +144,7 @@

{$_('oauthDelegation.loading')}

- {:else if step === 'identifier'} + {:else}
@@ -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; - } diff --git a/migrations/20260316_cross_pds_delegation.sql b/migrations/20260316_cross_pds_delegation.sql new file mode 100644 index 0000000..b8333a3 --- /dev/null +++ b/migrations/20260316_cross_pds_delegation.sql @@ -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;