Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

feat: cross-pds delegation #52

merged opened by oyster.cafe targeting main from feat/cross-pds-delegation
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhbovxxqv622
+2072 -1696
Diff #0
+52
.sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "controller_did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle?", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "granted_scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "is_active!", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "is_local!", 34 + "type_info": "Bool" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + false, 47 + null, 48 + null 49 + ] 50 + }, 51 + "hash": "d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7" 52 + }
+52
.sqlx/query-dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "controller_did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle?", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "granted_scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "is_active!", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "is_local!", 34 + "type_info": "Bool" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + false, 47 + null, 48 + null 49 + ] 50 + }, 51 + "hash": "dd6021dd12823e042b011b2c1507736a46c0dcf0eb94cb41de58f8ca0b3a2f08" 52 + }
+22
.sqlx/query-ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count!", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "ff2ffeb1ea1c1375ff0edc4c9ce2f3cdeb92e2b0a72405ea0441b0340b177b96" 22 + }
+20 -20
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-api" 6097 - version = "0.4.2" 6097 + version = "0.4.3" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "axum", ··· 6142 6142 6143 6143 [[package]] 6144 6144 name = "tranquil-auth" 6145 - version = "0.4.2" 6145 + version = "0.4.3" 6146 6146 dependencies = [ 6147 6147 "anyhow", 6148 6148 "base32", ··· 6165 6165 6166 6166 [[package]] 6167 6167 name = "tranquil-cache" 6168 - version = "0.4.2" 6168 + version = "0.4.3" 6169 6169 dependencies = [ 6170 6170 "async-trait", 6171 6171 "base64 0.22.1", ··· 6179 6179 6180 6180 [[package]] 6181 6181 name = "tranquil-comms" 6182 - version = "0.4.2" 6182 + version = "0.4.3" 6183 6183 dependencies = [ 6184 6184 "async-trait", 6185 6185 "base64 0.22.1", ··· 6194 6194 6195 6195 [[package]] 6196 6196 name = "tranquil-config" 6197 - version = "0.4.2" 6197 + version = "0.4.3" 6198 6198 dependencies = [ 6199 6199 "confique", 6200 6200 "serde", ··· 6202 6202 6203 6203 [[package]] 6204 6204 name = "tranquil-crypto" 6205 - version = "0.4.2" 6205 + version = "0.4.3" 6206 6206 dependencies = [ 6207 6207 "aes-gcm", 6208 6208 "base64 0.22.1", ··· 6218 6218 6219 6219 [[package]] 6220 6220 name = "tranquil-db" 6221 - version = "0.4.2" 6221 + version = "0.4.3" 6222 6222 dependencies = [ 6223 6223 "async-trait", 6224 6224 "chrono", ··· 6235 6235 6236 6236 [[package]] 6237 6237 name = "tranquil-db-traits" 6238 - version = "0.4.2" 6238 + version = "0.4.3" 6239 6239 dependencies = [ 6240 6240 "async-trait", 6241 6241 "base64 0.22.1", ··· 6251 6251 6252 6252 [[package]] 6253 6253 name = "tranquil-infra" 6254 - version = "0.4.2" 6254 + version = "0.4.3" 6255 6255 dependencies = [ 6256 6256 "async-trait", 6257 6257 "bytes", ··· 6262 6262 6263 6263 [[package]] 6264 6264 name = "tranquil-lexicon" 6265 - version = "0.4.2" 6265 + version = "0.4.3" 6266 6266 dependencies = [ 6267 6267 "chrono", 6268 6268 "hickory-resolver", ··· 6280 6280 6281 6281 [[package]] 6282 6282 name = "tranquil-oauth" 6283 - version = "0.4.2" 6283 + version = "0.4.3" 6284 6284 dependencies = [ 6285 6285 "anyhow", 6286 6286 "axum", ··· 6303 6303 6304 6304 [[package]] 6305 6305 name = "tranquil-oauth-server" 6306 - version = "0.4.2" 6306 + version = "0.4.3" 6307 6307 dependencies = [ 6308 6308 "axum", 6309 6309 "base64 0.22.1", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-pds" 6339 - version = "0.4.2" 6339 + version = "0.4.3" 6340 6340 dependencies = [ 6341 6341 "aes-gcm", 6342 6342 "anyhow", ··· 6424 6424 6425 6425 [[package]] 6426 6426 name = "tranquil-repo" 6427 - version = "0.4.2" 6427 + version = "0.4.3" 6428 6428 dependencies = [ 6429 6429 "bytes", 6430 6430 "cid", ··· 6436 6436 6437 6437 [[package]] 6438 6438 name = "tranquil-ripple" 6439 - version = "0.4.2" 6439 + version = "0.4.3" 6440 6440 dependencies = [ 6441 6441 "async-trait", 6442 6442 "backon", ··· 6461 6461 6462 6462 [[package]] 6463 6463 name = "tranquil-scopes" 6464 - version = "0.4.2" 6464 + version = "0.4.3" 6465 6465 dependencies = [ 6466 6466 "axum", 6467 6467 "futures", ··· 6477 6477 6478 6478 [[package]] 6479 6479 name = "tranquil-server" 6480 - version = "0.4.2" 6480 + version = "0.4.3" 6481 6481 dependencies = [ 6482 6482 "axum", 6483 6483 "clap", ··· 6497 6497 6498 6498 [[package]] 6499 6499 name = "tranquil-storage" 6500 - version = "0.4.2" 6500 + version = "0.4.3" 6501 6501 dependencies = [ 6502 6502 "async-trait", 6503 6503 "aws-config", ··· 6514 6514 6515 6515 [[package]] 6516 6516 name = "tranquil-sync" 6517 - version = "0.4.2" 6517 + version = "0.4.3" 6518 6518 dependencies = [ 6519 6519 "anyhow", 6520 6520 "axum", ··· 6536 6536 6537 6537 [[package]] 6538 6538 name = "tranquil-types" 6539 - version = "0.4.2" 6539 + version = "0.4.3" 6540 6540 dependencies = [ 6541 6541 "chrono", 6542 6542 "cid",
+1 -1
Cargo.toml
··· 24 24 ] 25 25 26 26 [workspace.package] 27 - version = "0.4.2" 27 + version = "0.4.3" 28 28 edition = "2024" 29 29 license = "AGPL-3.0-or-later" 30 30
+2 -1
Dockerfile
··· 4 4 RUN deno task build 5 5 6 6 FROM rust:1.92-alpine AS builder 7 - RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static 7 + RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang 8 + ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" 8 9 WORKDIR /app 9 10 ARG SLIM="false" 10 11 COPY Cargo.toml Cargo.lock ./
+116 -247
crates/tranquil-api/src/delegation.rs
··· 1 + use crate::identity::provision::{create_plc_did, init_genesis_repo}; 1 2 use tranquil_pds::api::error::ApiError; 2 - use tranquil_pds::repo_ops::create_signed_commit; 3 3 use tranquil_pds::auth::{Active, Auth}; 4 4 use tranquil_pds::delegation::{ 5 5 DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, 6 - verify_can_be_controller, verify_can_control_accounts, 6 + verify_can_control_accounts, 7 7 }; 8 8 use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited}; 9 9 use tranquil_pds::state::AppState; ··· 14 14 http::StatusCode, 15 15 response::{IntoResponse, Response}, 16 16 }; 17 - use jacquard_common::types::{integer::LimitedU32, string::Tid}; 18 - use jacquard_repo::{mst::Mst, storage::BlockStore}; 19 17 use serde::{Deserialize, Serialize}; 20 18 use serde_json::json; 21 - use std::sync::Arc; 22 19 use tracing::{error, info, warn}; 23 20 24 - #[derive(Debug, Serialize)] 25 - #[serde(rename_all = "camelCase")] 26 - pub struct ControllerInfo { 27 - pub did: Did, 28 - pub handle: Handle, 29 - pub granted_scopes: String, 30 - pub granted_at: chrono::DateTime<chrono::Utc>, 31 - pub is_active: bool, 32 - } 33 - 34 - #[derive(Debug, Serialize)] 35 - pub struct ListControllersResponse { 36 - pub controllers: Vec<ControllerInfo>, 37 - } 38 - 39 21 pub async fn list_controllers( 40 22 State(state): State<AppState>, 41 23 auth: Auth<Active>, ··· 54 36 } 55 37 }; 56 38 57 - Ok(Json(ListControllersResponse { 58 - controllers: controllers 59 - .into_iter() 60 - .map(|c| ControllerInfo { 61 - did: c.did, 62 - handle: c.handle, 63 - granted_scopes: c.granted_scopes.into_string(), 64 - granted_at: c.granted_at, 65 - is_active: c.is_active, 66 - }) 67 - .collect(), 68 - }) 69 - .into_response()) 39 + let resolve_futures = controllers.into_iter().map(|mut c| { 40 + let did_resolver = state.did_resolver.clone(); 41 + async move { 42 + if c.handle.is_none() { 43 + c.handle = did_resolver 44 + .resolve_did_document(c.did.as_str()) 45 + .await 46 + .and_then(|doc| tranquil_types::did_doc::extract_handle(&doc)) 47 + .map(|h| h.into()); 48 + } 49 + c 50 + } 51 + }); 52 + 53 + let controllers = futures::future::join_all(resolve_futures).await; 54 + 55 + Ok(Json(serde_json::json!({ "controllers": controllers })).into_response()) 70 56 } 71 57 72 58 #[derive(Debug, Deserialize)] ··· 80 66 auth: Auth<Active>, 81 67 Json(input): Json<AddControllerInput>, 82 68 ) -> Result<Response, ApiError> { 83 - let controller_exists = state 84 - .user_repo 85 - .get_by_did(&input.controller_did) 69 + let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did) 86 70 .await 87 - .ok() 88 - .flatten() 89 - .is_some(); 90 - 91 - if !controller_exists { 92 - return Ok(ApiError::ControllerNotFound.into_response()); 71 + .ok_or(ApiError::ControllerNotFound)?; 72 + 73 + if !resolved.is_local { 74 + if let Some(ref pds_url) = resolved.pds_url { 75 + if !pds_url.starts_with("https://") { 76 + return Ok( 77 + ApiError::InvalidDelegation("Controller PDS must use HTTPS".into()) 78 + .into_response(), 79 + ); 80 + } 81 + match state 82 + .cross_pds_oauth 83 + .check_remote_is_delegated(pds_url, input.controller_did.as_str()) 84 + .await 85 + { 86 + Some(true) => { 87 + return Ok(ApiError::InvalidDelegation( 88 + "Cannot add a delegated account from another PDS as a controller".into(), 89 + ) 90 + .into_response()); 91 + } 92 + Some(false) => {} 93 + None => { 94 + warn!( 95 + controller = %input.controller_did, 96 + pds = %pds_url, 97 + "Could not verify remote controller delegation status" 98 + ); 99 + } 100 + } 101 + } 93 102 } 94 103 95 104 let can_add = match verify_can_add_controllers(&state, &auth).await { ··· 97 106 Err(response) => return Ok(response), 98 107 }; 99 108 100 - let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await { 101 - Ok(proof) => proof, 102 - Err(response) => return Ok(response), 103 - }; 109 + if resolved.is_local { 110 + if state.delegation_repo.is_delegated_account(&input.controller_did).await.unwrap_or(false) { 111 + return Ok(ApiError::InvalidDelegation( 112 + "Cannot add a controlled account as a controller".into(), 113 + ).into_response()); 114 + } 115 + } 104 116 105 117 match state 106 118 .delegation_repo 107 119 .create_delegation( 108 120 can_add.did(), 109 - can_be_controller.did(), 121 + &input.controller_did, 110 122 &input.granted_scopes, 111 123 can_add.did(), 112 124 ) ··· 118 130 .log_delegation_action( 119 131 can_add.did(), 120 132 can_add.did(), 121 - Some(can_be_controller.did()), 133 + Some(&input.controller_did), 122 134 DelegationActionType::GrantCreated, 123 135 Some(serde_json::json!({ 124 - "granted_scopes": input.granted_scopes.as_str() 136 + "granted_scopes": input.granted_scopes.as_str(), 137 + "is_local": resolved.is_local 125 138 })), 126 139 None, 127 140 None, ··· 256 269 } 257 270 } 258 271 259 - #[derive(Debug, Serialize)] 260 - #[serde(rename_all = "camelCase")] 261 - pub struct DelegatedAccountInfo { 262 - pub did: Did, 263 - pub handle: Handle, 264 - pub granted_scopes: String, 265 - pub granted_at: chrono::DateTime<chrono::Utc>, 266 - } 267 - 268 - #[derive(Debug, Serialize)] 269 - pub struct ListControlledAccountsResponse { 270 - pub accounts: Vec<DelegatedAccountInfo>, 271 - } 272 - 273 272 pub async fn list_controlled_accounts( 274 273 State(state): State<AppState>, 275 274 auth: Auth<Active>, ··· 289 288 } 290 289 }; 291 290 292 - Ok(Json(ListControlledAccountsResponse { 293 - accounts: accounts 294 - .into_iter() 295 - .map(|a| DelegatedAccountInfo { 296 - did: a.did, 297 - handle: a.handle, 298 - granted_scopes: a.granted_scopes.into_string(), 299 - granted_at: a.granted_at, 300 - }) 301 - .collect(), 302 - }) 303 - .into_response()) 291 + Ok(Json(serde_json::json!({ "accounts": accounts })).into_response()) 304 292 } 305 293 306 294 #[derive(Debug, Deserialize)] ··· 315 303 50 316 304 } 317 305 318 - #[derive(Debug, Serialize)] 319 - #[serde(rename_all = "camelCase")] 320 - pub struct AuditLogEntry { 321 - pub id: String, 322 - pub delegated_did: Did, 323 - pub actor_did: Did, 324 - pub controller_did: Option<Did>, 325 - pub action_type: String, 326 - pub action_details: Option<serde_json::Value>, 327 - pub created_at: chrono::DateTime<chrono::Utc>, 328 - } 329 - 330 - #[derive(Debug, Serialize)] 331 - pub struct GetAuditLogResponse { 332 - pub entries: Vec<AuditLogEntry>, 333 - pub total: i64, 334 - } 335 - 336 306 pub async fn get_audit_log( 337 307 State(state): State<AppState>, 338 308 auth: Auth<Active>, ··· 361 331 .await 362 332 .unwrap_or_default(); 363 333 364 - Ok(Json(GetAuditLogResponse { 365 - entries: entries 366 - .into_iter() 367 - .map(|e| AuditLogEntry { 368 - id: e.id.to_string(), 369 - delegated_did: e.delegated_did, 370 - actor_did: e.actor_did, 371 - controller_did: e.controller_did, 372 - action_type: format!("{:?}", e.action_type), 373 - action_details: e.action_details, 374 - created_at: e.created_at, 375 - }) 376 - .collect(), 377 - total, 378 - }) 379 - .into_response()) 380 - } 381 - 382 - #[derive(Debug, Serialize)] 383 - pub struct ScopePresetInfo { 384 - pub name: &'static str, 385 - pub label: &'static str, 386 - pub description: &'static str, 387 - pub scopes: &'static str, 388 - } 389 - 390 - #[derive(Debug, Serialize)] 391 - pub struct GetScopePresetsResponse { 392 - pub presets: Vec<ScopePresetInfo>, 334 + Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response()) 393 335 } 394 336 395 337 pub async fn get_scope_presets() -> Response { 396 - Json(GetScopePresetsResponse { 397 - presets: SCOPE_PRESETS 398 - .iter() 399 - .map(|p| ScopePresetInfo { 400 - name: p.name, 401 - label: p.label, 402 - description: p.description, 403 - scopes: p.scopes, 404 - }) 405 - .collect(), 406 - }) 407 - .into_response() 338 + Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response() 408 339 } 409 340 410 341 #[derive(Debug, Deserialize)] ··· 434 365 Err(response) => return Ok(response), 435 366 }; 436 367 437 - let hostname = &tranquil_config::get().server.hostname; 438 - let available_domains = tranquil_config::get().server.available_user_domain_list(); 439 - let matched_domain = available_domains 440 - .iter() 441 - .filter(|d| input.handle.ends_with(&format!(".{}", d))) 442 - .max_by_key(|d| d.len()); 443 - 444 - let handle = if !input.handle.contains('.') || matched_domain.is_some() { 445 - let handle_to_validate = match matched_domain { 446 - Some(domain) => input 447 - .handle 448 - .strip_suffix(&format!(".{}", domain)) 449 - .unwrap_or(&input.handle), 450 - None => &input.handle, 451 - }; 452 - match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) { 453 - Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 454 - Err(e) => { 455 - return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); 456 - } 368 + let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { 369 + Ok(h) => h, 370 + Err(e) => { 371 + return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); 457 372 } 458 - } else { 459 - input.handle.to_lowercase() 460 373 }; 461 374 462 375 let email = input ··· 483 396 None 484 397 }; 485 398 486 - use k256::ecdsa::SigningKey; 487 - use rand::rngs::OsRng; 488 - 489 - let pds_endpoint = format!("https://{}", hostname); 490 - let secret_key = k256::SecretKey::random(&mut OsRng); 491 - let secret_key_bytes = secret_key.to_bytes().to_vec(); 492 - 493 - let signing_key = match SigningKey::from_slice(&secret_key_bytes) { 494 - Ok(k) => k, 495 - Err(e) => { 496 - error!("Error creating signing key: {:?}", e); 497 - return Ok(ApiError::InternalError(None).into_response()); 498 - } 499 - }; 500 - 501 - let rotation_key = tranquil_config::get() 502 - .secrets 503 - .plc_rotation_key 504 - .clone() 505 - .unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&signing_key)); 506 - 507 - let genesis_result = match tranquil_pds::plc::create_genesis_operation( 508 - &signing_key, 509 - &rotation_key, 510 - &handle, 511 - &pds_endpoint, 512 - ) { 513 - Ok(r) => r, 514 - Err(e) => { 515 - error!("Error creating PLC genesis operation: {:?}", e); 516 - return Ok( 517 - ApiError::InternalError(Some("Failed to create PLC operation".into())) 518 - .into_response(), 519 - ); 520 - } 521 - }; 522 - 523 - let plc_client = tranquil_pds::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 524 - if let Err(e) = plc_client 525 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 526 - .await 527 - { 528 - error!("Failed to submit PLC genesis operation: {:?}", e); 529 - return Ok(ApiError::UpstreamErrorMsg(format!( 530 - "Failed to register DID with PLC directory: {}", 531 - e 532 - )) 533 - .into_response()); 534 - } 535 - 536 - let did: Did = genesis_result 537 - .did 538 - .parse() 539 - .map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?; 399 + let plc = create_plc_did(&state, &handle).await.map_err(|e| { 400 + tracing::error!("PLC DID creation failed: {:?}", e); 401 + e 402 + })?; 403 + let did = plc.did; 540 404 let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; 541 405 info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); 542 406 543 - let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) { 544 - Ok(bytes) => bytes, 545 - Err(e) => { 546 - error!("Error encrypting signing key: {:?}", e); 547 - return Ok(ApiError::InternalError(None).into_response()); 548 - } 549 - }; 550 - 551 - let mst = Mst::new(Arc::new(state.block_store.clone())); 552 - let mst_root = match mst.persist().await { 553 - Ok(c) => c, 554 - Err(e) => { 555 - error!("Error persisting MST: {:?}", e); 556 - return Ok(ApiError::InternalError(None).into_response()); 557 - } 558 - }; 559 - let rev = Tid::now(LimitedU32::MIN); 560 - let (commit_bytes, _sig) = 561 - match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) { 562 - Ok(result) => result, 563 - Err(e) => { 564 - error!("Error creating genesis commit: {:?}", e); 565 - return Ok(ApiError::InternalError(None).into_response()); 566 - } 567 - }; 568 - let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 569 - Ok(c) => c, 570 - Err(e) => { 571 - error!("Error saving genesis commit: {:?}", e); 572 - return Ok(ApiError::InternalError(None).into_response()); 573 - } 574 - }; 575 - let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 407 + let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?; 576 408 577 409 let create_input = tranquil_db_traits::CreateDelegatedAccountInput { 578 410 handle: handle.clone(), ··· 580 412 did: did.clone(), 581 413 controller_did: can_control.did().clone(), 582 414 controller_scopes: input.controller_scopes.as_str().to_string(), 583 - encrypted_key_bytes, 415 + encrypted_key_bytes: repo.encrypted_key_bytes, 584 416 encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, 585 - commit_cid: commit_cid.to_string(), 586 - repo_rev: rev.as_ref().to_string(), 587 - genesis_block_cids, 417 + commit_cid: repo.commit_cid.to_string(), 418 + repo_rev: repo.repo_rev.clone(), 419 + genesis_block_cids: repo.genesis_block_cids, 588 420 invite_code: input.invite_code.clone(), 589 421 }; 590 422 ··· 666 498 667 499 Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response()) 668 500 } 501 + 502 + #[derive(Debug, Deserialize)] 503 + pub struct ResolveControllerParams { 504 + pub identifier: String, 505 + } 506 + 507 + pub async fn resolve_controller( 508 + State(state): State<AppState>, 509 + Query(params): Query<ResolveControllerParams>, 510 + ) -> Result<Response, ApiError> { 511 + let identifier = params.identifier.trim().trim_start_matches('@'); 512 + 513 + let did: Did = if identifier.starts_with("did:") { 514 + identifier.parse().map_err(|_| ApiError::ControllerNotFound)? 515 + } else { 516 + let local_handle: Option<Handle> = identifier.parse().ok(); 517 + let local_user = match local_handle { 518 + Some(ref h) => state.user_repo.get_by_handle(h).await.ok().flatten(), 519 + None => None, 520 + }; 521 + match local_user { 522 + Some(user) => user.did, 523 + None => tranquil_pds::handle::resolve_handle(identifier) 524 + .await 525 + .map_err(|_| ApiError::ControllerNotFound)? 526 + .parse() 527 + .map_err(|_| ApiError::ControllerNotFound)?, 528 + } 529 + }; 530 + 531 + let resolved = tranquil_pds::delegation::resolve_identity(&state, &did) 532 + .await 533 + .ok_or(ApiError::ControllerNotFound)?; 534 + 535 + Ok(Json(resolved).into_response()) 536 + } 537 +
+28 -142
crates/tranquil-api/src/identity/account.rs
··· 1 1 use super::did::verify_did_web; 2 2 use tranquil_pds::api::error::ApiError; 3 - use tranquil_pds::repo_ops::create_signed_commit; 4 3 use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token}; 5 - use tranquil_pds::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 6 4 use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited}; 7 5 use tranquil_pds::state::AppState; 8 6 use tranquil_pds::types::{Did, Handle, PlainPassword}; ··· 14 12 response::{IntoResponse, Response}, 15 13 }; 16 14 use bcrypt::{DEFAULT_COST, hash}; 17 - use jacquard_common::types::{integer::LimitedU32, string::Tid}; 18 - use jacquard_repo::{mst::Mst, storage::BlockStore}; 19 15 use k256::{SecretKey, ecdsa::SigningKey}; 20 16 use rand::rngs::OsRng; 21 17 use serde::{Deserialize, Serialize}; 22 18 use serde_json::json; 23 - use std::sync::Arc; 24 19 use tracing::{debug, error, info, warn}; 25 20 26 21 #[derive(Deserialize)] ··· 141 136 } 142 137 143 138 let cfg = tranquil_config::get(); 144 - let available_domains = cfg.server.available_user_domain_list(); 145 - let matched_domain = available_domains 146 - .iter() 147 - .filter(|d| input.handle.ends_with(&format!(".{}", d))) 148 - .max_by_key(|d| d.len()); 149 - 150 - let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() { 151 - let handle_to_validate = match matched_domain { 152 - Some(domain) => input 153 - .handle 154 - .strip_suffix(&format!(".{}", domain)) 155 - .unwrap_or(&input.handle), 156 - None => &input.handle, 157 - }; 158 - match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) { 159 - Ok(h) => h, 160 - Err(e) => { 161 - return ApiError::from(e).into_response(); 162 - } 163 - } 164 - } else { 165 - match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) { 166 - Ok(h) => h, 167 - Err(e) => return ApiError::from(e).into_response(), 168 - } 139 + let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { 140 + Ok(h) => h, 141 + Err(e) => return ApiError::from(e).into_response(), 169 142 }; 170 143 let email: Option<String> = input 171 144 .email ··· 221 194 }) 222 195 }; 223 196 let hostname = &cfg.server.hostname; 224 - let pds_endpoint = format!("https://{}", hostname); 225 - let handle = match matched_domain { 226 - Some(domain) => format!("{}.{}", validated_short_handle, domain), 227 - None if input.handle.contains('.') => validated_short_handle.clone(), 228 - None => format!("{}.{}", validated_short_handle, &available_domains[0]), 229 - }; 230 197 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 231 198 if let Some(signing_key_did) = &input.signing_key { 232 199 match state ··· 308 275 ) 309 276 .into_response(); 310 277 } else { 311 - let rotation_key = tranquil_config::get() 312 - .secrets 313 - .plc_rotation_key 314 - .clone() 315 - .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); 316 - let genesis_result = match create_genesis_operation( 317 - &signing_key, 318 - &rotation_key, 319 - &handle, 320 - &pds_endpoint, 321 - ) { 322 - Ok(r) => r, 323 - Err(e) => { 324 - error!("Error creating PLC genesis operation: {:?}", e); 325 - return ApiError::InternalError(Some( 326 - "Failed to create PLC operation".into(), 327 - )) 328 - .into_response(); 329 - } 330 - }; 331 - let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); 332 - if let Err(e) = plc_client 333 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 334 - .await 278 + match super::provision::submit_plc_genesis(&state, &signing_key, &handle).await 335 279 { 336 - error!("Failed to submit PLC genesis operation: {:?}", e); 337 - return ApiError::UpstreamErrorMsg(format!( 338 - "Failed to register DID with PLC directory: {}", 339 - e 340 - )) 341 - .into_response(); 280 + Ok(did) => did, 281 + Err(e) => return e.into_response(), 342 282 } 343 - info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 344 - genesis_result.did 345 283 } 346 284 } else { 347 - let rotation_key = tranquil_config::get() 348 - .secrets 349 - .plc_rotation_key 350 - .clone() 351 - .unwrap_or_else(|| signing_key_to_did_key(&signing_key)); 352 - let genesis_result = match create_genesis_operation( 353 - &signing_key, 354 - &rotation_key, 355 - &handle, 356 - &pds_endpoint, 357 - ) { 358 - Ok(r) => r, 359 - Err(e) => { 360 - error!("Error creating PLC genesis operation: {:?}", e); 361 - return ApiError::InternalError(Some( 362 - "Failed to create PLC operation".into(), 363 - )) 364 - .into_response(); 365 - } 366 - }; 367 - let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); 368 - if let Err(e) = plc_client 369 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 370 - .await 371 - { 372 - error!("Failed to submit PLC genesis operation: {:?}", e); 373 - return ApiError::UpstreamErrorMsg(format!( 374 - "Failed to register DID with PLC directory: {}", 375 - e 376 - )) 377 - .into_response(); 285 + match super::provision::submit_plc_genesis(&state, &signing_key, &handle).await { 286 + Ok(did) => did, 287 + Err(e) => return e.into_response(), 378 288 } 379 - info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 380 - genesis_result.did 381 289 } 382 290 } 383 291 }; ··· 453 361 refresh_expires_at: refresh_meta.expires_at, 454 362 login_type: tranquil_db_traits::LoginType::Modern, 455 363 mfa_verified: false, 456 - scope: None, 364 + scope: Some("transition:generic transition:chat.bsky".to_string()), 457 365 controller_did: None, 458 366 app_password_name: None, 459 367 }; ··· 590 498 None 591 499 }; 592 500 593 - let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) { 594 - Ok(enc) => enc, 595 - Err(e) => { 596 - error!("Error encrypting user key: {:?}", e); 597 - return ApiError::InternalError(None).into_response(); 598 - } 599 - }; 600 - 601 - let mst = Mst::new(Arc::new(state.block_store.clone())); 602 - let mst_root = match mst.persist().await { 603 - Ok(c) => c, 604 - Err(e) => { 605 - error!("Error persisting MST: {:?}", e); 606 - return ApiError::InternalError(None).into_response(); 607 - } 608 - }; 609 - let rev = Tid::now(LimitedU32::MIN); 610 501 let did_for_commit: Did = match did.parse() { 611 502 Ok(d) => d, 612 503 Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), 613 504 }; 614 - let (commit_bytes, _sig) = 615 - match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) { 616 - Ok(result) => result, 617 - Err(e) => { 618 - error!("Error creating genesis commit: {:?}", e); 619 - return ApiError::InternalError(None).into_response(); 620 - } 621 - }; 622 - let commit_cid = match state.block_store.put(&commit_bytes).await { 623 - Ok(c) => c, 624 - Err(e) => { 625 - error!("Error saving genesis commit: {:?}", e); 626 - return ApiError::InternalError(None).into_response(); 627 - } 505 + let repo = match super::provision::init_genesis_repo( 506 + &state, 507 + &did_for_commit, 508 + &signing_key, 509 + &secret_key_bytes, 510 + ) 511 + .await 512 + { 513 + Ok(r) => r, 514 + Err(e) => return e.into_response(), 628 515 }; 629 - let commit_cid_str = commit_cid.to_string(); 630 - let rev_str = rev.as_ref().to_string(); 631 - let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 516 + let commit_cid_str = repo.commit_cid.to_string(); 517 + let rev_str = repo.repo_rev.clone(); 632 518 633 519 let birthdate_pref = if tranquil_config::get().server.age_assurance_override { 634 520 Some(json!({ ··· 665 551 .filter(|s| !s.is_empty()) 666 552 .map(|s| s.to_lowercase()), 667 553 deactivated_at, 668 - encrypted_key_bytes, 554 + encrypted_key_bytes: repo.encrypted_key_bytes, 669 555 encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, 670 556 reserved_key_id, 671 557 commit_cid: commit_cid_str.clone(), 672 558 repo_rev: rev_str.clone(), 673 - genesis_block_cids, 559 + genesis_block_cids: repo.genesis_block_cids, 674 560 invite_code: if is_bootstrap { 675 561 None 676 562 } else { ··· 718 604 if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit( 719 605 &state, 720 606 &did_for_commit, 721 - &commit_cid, 722 - &mst_root, 607 + &repo.commit_cid, 608 + &repo.mst_root_cid, 723 609 &rev_str, 724 610 ) 725 611 .await ··· 730 616 &state, 731 617 &did_for_commit, 732 618 &commit_cid_str, 733 - Some(rev.as_ref()), 619 + Some(&rev_str), 734 620 ) 735 621 .await 736 622 { ··· 821 707 refresh_expires_at: refresh_meta.expires_at, 822 708 login_type: tranquil_db_traits::LoginType::Modern, 823 709 mfa_verified: false, 824 - scope: None, 710 + scope: Some("transition:generic transition:chat.bsky".to_string()), 825 711 controller_did: None, 826 712 app_password_name: None, 827 713 };
+1 -1
crates/tranquil-api/src/identity/did.rs
··· 813 813 }; 814 814 let key_bytes = tranquil_pds::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?; 815 815 let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?; 816 - let plc_client = tranquil_pds::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 816 + let plc_client = state.plc_client(); 817 817 let last_op = plc_client.get_last_op(did).await?; 818 818 let new_also_known_as = vec![format!("at://{}", new_handle)]; 819 819 let update_op =
+1
crates/tranquil-api/src/identity/mod.rs
··· 2 2 pub mod did; 3 3 pub mod handle; 4 4 pub mod plc; 5 + pub mod provision; 5 6 6 7 pub use account::create_account; 7 8 pub use did::{
+2 -2
crates/tranquil-api/src/identity/plc/sign.rs
··· 2 2 use tranquil_pds::api::error::DbResultExt; 3 3 use tranquil_pds::auth::{Auth, Permissive}; 4 4 use tranquil_pds::circuit_breaker::with_circuit_breaker; 5 - use tranquil_pds::plc::{PlcClient, PlcError, PlcService, ServiceType, create_update_op, sign_operation}; 5 + use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation}; 6 6 use tranquil_pds::state::AppState; 7 7 use axum::{ 8 8 Json, ··· 97 97 ApiError::InternalError(None) 98 98 })?; 99 99 100 - let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); 100 + let plc_client = state.plc_client(); 101 101 let did_clone = did.clone(); 102 102 let last_op = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { 103 103 plc_client.get_last_op(&did_clone).await
+2 -2
crates/tranquil-api/src/identity/plc/submit.rs
··· 2 2 use tranquil_pds::api::{ApiError, EmptyResponse}; 3 3 use tranquil_pds::auth::{Auth, Permissive}; 4 4 use tranquil_pds::circuit_breaker::with_circuit_breaker; 5 - use tranquil_pds::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; 5 + use tranquil_pds::plc::{signing_key_to_did_key, validate_plc_operation}; 6 6 use tranquil_pds::state::AppState; 7 7 use axum::{ 8 8 Json, ··· 120 120 )); 121 121 } 122 122 } 123 - let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); 123 + let plc_client = state.plc_client(); 124 124 let operation_clone = input.operation.clone(); 125 125 let did_clone = did.clone(); 126 126 with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
+117
crates/tranquil-api/src/identity/provision.rs
··· 1 + use tranquil_pds::api::error::ApiError; 2 + use tranquil_pds::repo_ops::create_signed_commit; 3 + use tranquil_pds::state::AppState; 4 + use tranquil_pds::types::Did; 5 + use jacquard_common::types::{integer::LimitedU32, string::Tid}; 6 + use jacquard_repo::{mst::Mst, storage::BlockStore}; 7 + use k256::ecdsa::SigningKey; 8 + use std::sync::Arc; 9 + 10 + pub struct PlcDidResult { 11 + pub did: Did, 12 + pub signing_key_bytes: Vec<u8>, 13 + pub signing_key: SigningKey, 14 + } 15 + 16 + pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResult, ApiError> { 17 + use k256::SecretKey; 18 + use rand::rngs::OsRng; 19 + 20 + let secret_key = SecretKey::random(&mut OsRng); 21 + let secret_key_bytes = secret_key.to_bytes().to_vec(); 22 + let signing_key = SigningKey::from_slice(&secret_key_bytes).map_err(|e| { 23 + tracing::error!("Error creating signing key: {:?}", e); 24 + ApiError::InternalError(None) 25 + })?; 26 + 27 + let did_str = submit_plc_genesis(state, &signing_key, handle).await?; 28 + let did: Did = did_str 29 + .parse() 30 + .map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?; 31 + 32 + Ok(PlcDidResult { 33 + did, 34 + signing_key_bytes: secret_key_bytes, 35 + signing_key, 36 + }) 37 + } 38 + 39 + pub async fn submit_plc_genesis( 40 + state: &AppState, 41 + signing_key: &SigningKey, 42 + handle: &str, 43 + ) -> Result<String, ApiError> { 44 + let hostname = &tranquil_config::get().server.hostname; 45 + let pds_endpoint = format!("https://{}", hostname); 46 + 47 + let rotation_key = tranquil_config::get() 48 + .secrets 49 + .plc_rotation_key 50 + .clone() 51 + .unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key)); 52 + 53 + let genesis_result = 54 + tranquil_pds::plc::create_genesis_operation(signing_key, &rotation_key, handle, &pds_endpoint) 55 + .map_err(|e| { 56 + tracing::error!("Error creating PLC genesis operation: {:?}", e); 57 + ApiError::InternalError(Some("Failed to create PLC operation".into())) 58 + })?; 59 + 60 + state 61 + .plc_client() 62 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 63 + .await 64 + .map_err(|e| { 65 + tracing::error!("Failed to submit PLC genesis operation: {:?}", e); 66 + ApiError::UpstreamErrorMsg(format!("Failed to register DID with PLC directory: {}", e)) 67 + })?; 68 + 69 + tracing::info!(did = %genesis_result.did, "Registered DID with PLC directory"); 70 + Ok(genesis_result.did) 71 + } 72 + 73 + pub struct GenesisRepo { 74 + pub encrypted_key_bytes: Vec<u8>, 75 + pub commit_cid: cid::Cid, 76 + pub mst_root_cid: cid::Cid, 77 + pub repo_rev: String, 78 + pub genesis_block_cids: Vec<Vec<u8>>, 79 + } 80 + 81 + pub async fn init_genesis_repo( 82 + state: &AppState, 83 + did: &Did, 84 + signing_key: &SigningKey, 85 + signing_key_bytes: &[u8], 86 + ) -> Result<GenesisRepo, ApiError> { 87 + let encrypted_key_bytes = tranquil_pds::config::encrypt_key(signing_key_bytes).map_err(|e| { 88 + tracing::error!("Error encrypting signing key: {:?}", e); 89 + ApiError::InternalError(None) 90 + })?; 91 + 92 + let mst = Mst::new(Arc::new(state.block_store.clone())); 93 + let mst_root = mst.persist().await.map_err(|e| { 94 + tracing::error!("Error persisting MST: {:?}", e); 95 + ApiError::InternalError(None) 96 + })?; 97 + 98 + let rev = Tid::now(LimitedU32::MIN); 99 + let (commit_bytes, _sig) = create_signed_commit(did, mst_root, rev.as_ref(), None, signing_key) 100 + .map_err(|e| { 101 + tracing::error!("Error creating genesis commit: {:?}", e); 102 + ApiError::InternalError(None) 103 + })?; 104 + 105 + let commit_cid: cid::Cid = state.block_store.put(&commit_bytes).await.map_err(|e| { 106 + tracing::error!("Error saving genesis commit: {:?}", e); 107 + ApiError::InternalError(None) 108 + })?; 109 + 110 + Ok(GenesisRepo { 111 + encrypted_key_bytes, 112 + commit_cid, 113 + mst_root_cid: mst_root, 114 + repo_rev: rev.as_ref().to_string(), 115 + genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()], 116 + }) 117 + }
+1
crates/tranquil-api/src/lib.rs
··· 228 228 .route("/_delegation.getAuditLog", get(delegation::get_audit_log)) 229 229 .route("/_delegation.getScopePresets", get(delegation::get_scope_presets)) 230 230 .route("/_delegation.createDelegatedAccount", post(delegation::create_delegated_account)) 231 + .route("/_delegation.resolveController", get(delegation::resolve_controller)) 231 232 .route("/_backup.listBackups", get(backup::list_backups)) 232 233 .route("/_backup.getBackup", get(backup::get_backup)) 233 234 .route("/_backup.createBackup", post(backup::create_backup))
+4 -26
crates/tranquil-api/src/server/passkey_account.rs
··· 114 114 115 115 let cfg = tranquil_config::get(); 116 116 let hostname = &cfg.server.hostname; 117 - let available_domains = cfg.server.available_user_domain_list(); 118 - let matched_domain = available_domains 119 - .iter() 120 - .filter(|d| input.handle.ends_with(&format!(".{}", d))) 121 - .max_by_key(|d| d.len()); 122 - 123 - let handle = if !input.handle.contains('.') || matched_domain.is_some() { 124 - let handle_to_validate = match matched_domain { 125 - Some(domain) => input 126 - .handle 127 - .strip_suffix(&format!(".{}", domain)) 128 - .unwrap_or(&input.handle), 129 - None => &input.handle, 130 - }; 131 - match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) { 132 - Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 133 - Err(_) => { 134 - return ApiError::InvalidHandle(None).into_response(); 135 - } 136 - } 137 - } else { 138 - match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) { 139 - Ok(h) => h, 140 - Err(_) => return ApiError::InvalidHandle(None).into_response(), 141 - } 117 + let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { 118 + Ok(h) => h, 119 + Err(_) => return ApiError::InvalidHandle(None).into_response(), 142 120 }; 143 121 144 122 let email = input ··· 558 536 refresh_expires_at: refresh_expires, 559 537 login_type: tranquil_db::LoginType::Modern, 560 538 mfa_verified: false, 561 - scope: None, 539 + scope: Some("transition:generic".to_string()), 562 540 controller_did: None, 563 541 app_password_name: None, 564 542 };
+1 -1
crates/tranquil-api/src/server/session.rs
··· 708 708 refresh_expires_at: refresh_meta.expires_at, 709 709 login_type: tranquil_db_traits::LoginType::Modern, 710 710 mfa_verified: false, 711 - scope: None, 711 + scope: Some("transition:generic transition:chat.bsky".to_string()), 712 712 controller_did: None, 713 713 app_password_name: None, 714 714 };
+7 -15
crates/tranquil-db-traits/src/delegation.rs
··· 20 20 } 21 21 22 22 #[derive(Debug, Clone, Serialize, Deserialize)] 23 + #[serde(rename_all = "camelCase")] 23 24 pub struct DelegatedAccountInfo { 24 25 pub did: Did, 25 26 pub handle: Handle, ··· 28 29 } 29 30 30 31 #[derive(Debug, Clone, Serialize, Deserialize)] 32 + #[serde(rename_all = "camelCase")] 31 33 pub struct ControllerInfo { 32 34 pub did: Did, 33 - pub handle: Handle, 35 + pub handle: Option<Handle>, 34 36 pub granted_scopes: DbScope, 35 37 pub granted_at: DateTime<Utc>, 36 38 pub is_active: bool, 39 + pub is_local: bool, 37 40 } 38 41 39 42 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ··· 48 51 } 49 52 50 53 #[derive(Debug, Clone, Serialize, Deserialize)] 54 + #[serde(rename_all = "camelCase")] 51 55 pub struct AuditLogEntry { 52 56 pub id: Uuid, 53 57 pub delegated_did: Did, ··· 55 59 pub controller_did: Option<Did>, 56 60 pub action_type: DelegationActionType, 57 61 pub action_details: Option<serde_json::Value>, 62 + #[serde(skip_serializing)] 58 63 pub ip_address: Option<String>, 64 + #[serde(skip_serializing)] 59 65 pub user_agent: Option<String>, 60 66 pub created_at: DateTime<Utc>, 61 67 } ··· 102 108 controller_did: &Did, 103 109 ) -> Result<Vec<DelegatedAccountInfo>, DbError>; 104 110 105 - async fn get_active_controllers_for_account( 106 - &self, 107 - delegated_did: &Did, 108 - ) -> Result<Vec<ControllerInfo>, DbError>; 109 - 110 111 async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError>; 111 112 112 - async fn has_any_controllers(&self, did: &Did) -> Result<bool, DbError>; 113 - 114 113 async fn controls_any_accounts(&self, did: &Did) -> Result<bool, DbError>; 115 114 116 115 #[allow(clippy::too_many_arguments)] ··· 132 131 offset: i64, 133 132 ) -> Result<Vec<AuditLogEntry>, DbError>; 134 133 135 - async fn get_audit_log_by_controller( 136 - &self, 137 - controller_did: &Did, 138 - limit: i64, 139 - offset: i64, 140 - ) -> Result<Vec<AuditLogEntry>, DbError>; 141 - 142 134 async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError>; 143 135 }
+13 -109
crates/tranquil-db/src/postgres/delegation.rs
··· 185 185 let rows = sqlx::query!( 186 186 r#" 187 187 SELECT 188 - u.did, 189 - u.handle, 188 + d.controller_did, 189 + u.handle as "handle?", 190 190 d.granted_scopes, 191 191 d.granted_at, 192 - (u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!" 192 + CASE WHEN u.did IS NOT NULL 193 + THEN u.deactivated_at IS NULL AND u.takedown_ref IS NULL 194 + ELSE true 195 + END as "is_active!", 196 + u.did IS NOT NULL as "is_local!" 193 197 FROM account_delegations d 194 - JOIN users u ON u.did = d.controller_did 198 + LEFT JOIN users u ON u.did = d.controller_did 195 199 WHERE d.delegated_did = $1 AND d.revoked_at IS NULL 196 200 ORDER BY d.granted_at DESC 197 201 "#, ··· 204 208 Ok(rows 205 209 .into_iter() 206 210 .map(|r| ControllerInfo { 207 - did: r.did.into(), 208 - handle: r.handle.into(), 211 + did: r.controller_did.into(), 212 + handle: r.handle.map(Into::into), 209 213 granted_scopes: DbScope::from_db(r.granted_scopes), 210 214 granted_at: r.granted_at, 211 215 is_active: r.is_active, 216 + is_local: r.is_local, 212 217 }) 213 218 .collect()) 214 219 } ··· 249 254 .collect()) 250 255 } 251 256 252 - async fn get_active_controllers_for_account( 253 - &self, 254 - delegated_did: &Did, 255 - ) -> Result<Vec<ControllerInfo>, DbError> { 256 - let rows = sqlx::query!( 257 - r#" 258 - SELECT 259 - u.did, 260 - u.handle, 261 - d.granted_scopes, 262 - d.granted_at, 263 - true as "is_active!" 264 - FROM account_delegations d 265 - JOIN users u ON u.did = d.controller_did 266 - WHERE d.delegated_did = $1 267 - AND d.revoked_at IS NULL 268 - AND u.deactivated_at IS NULL 269 - AND u.takedown_ref IS NULL 270 - ORDER BY d.granted_at DESC 271 - "#, 272 - delegated_did.as_str() 273 - ) 274 - .fetch_all(&self.pool) 275 - .await 276 - .map_err(map_sqlx_error)?; 277 - 278 - Ok(rows 279 - .into_iter() 280 - .map(|r| ControllerInfo { 281 - did: r.did.into(), 282 - handle: r.handle.into(), 283 - granted_scopes: DbScope::from_db(r.granted_scopes), 284 - granted_at: r.granted_at, 285 - is_active: r.is_active, 286 - }) 287 - .collect()) 288 - } 289 - 290 257 async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> { 291 258 let count = sqlx::query_scalar!( 292 259 r#" 293 260 SELECT COUNT(*) as "count!" 294 261 FROM account_delegations d 295 - JOIN users u ON u.did = d.controller_did 262 + LEFT JOIN users u ON u.did = d.controller_did 296 263 WHERE d.delegated_did = $1 297 264 AND d.revoked_at IS NULL 298 - AND u.deactivated_at IS NULL 299 - AND u.takedown_ref IS NULL 265 + AND (u.did IS NULL OR (u.deactivated_at IS NULL AND u.takedown_ref IS NULL)) 300 266 "#, 301 267 delegated_did.as_str() 302 268 ) ··· 307 273 Ok(count) 308 274 } 309 275 310 - async fn has_any_controllers(&self, did: &Did) -> Result<bool, DbError> { 311 - let exists = sqlx::query_scalar!( 312 - r#"SELECT EXISTS( 313 - SELECT 1 FROM account_delegations 314 - WHERE delegated_did = $1 AND revoked_at IS NULL 315 - ) as "exists!""#, 316 - did.as_str() 317 - ) 318 - .fetch_one(&self.pool) 319 - .await 320 - .map_err(map_sqlx_error)?; 321 - 322 - Ok(exists) 323 - } 324 - 325 276 async fn controls_any_accounts(&self, did: &Did) -> Result<bool, DbError> { 326 277 let exists = sqlx::query_scalar!( 327 278 r#"SELECT EXISTS( ··· 418 369 .collect()) 419 370 } 420 371 421 - async fn get_audit_log_by_controller( 422 - &self, 423 - controller_did: &Did, 424 - limit: i64, 425 - offset: i64, 426 - ) -> Result<Vec<AuditLogEntry>, DbError> { 427 - let rows = sqlx::query!( 428 - r#" 429 - SELECT 430 - id, 431 - delegated_did, 432 - actor_did, 433 - controller_did, 434 - action_type as "action_type: PgDelegationActionType", 435 - action_details, 436 - ip_address, 437 - user_agent, 438 - created_at 439 - FROM delegation_audit_log 440 - WHERE controller_did = $1 441 - ORDER BY created_at DESC 442 - LIMIT $2 OFFSET $3 443 - "#, 444 - controller_did.as_str(), 445 - limit, 446 - offset 447 - ) 448 - .fetch_all(&self.pool) 449 - .await 450 - .map_err(map_sqlx_error)?; 451 - 452 - Ok(rows 453 - .into_iter() 454 - .map(|r| AuditLogEntry { 455 - id: r.id, 456 - delegated_did: r.delegated_did.into(), 457 - actor_did: r.actor_did.into(), 458 - controller_did: r.controller_did.map(Into::into), 459 - action_type: r.action_type.into(), 460 - action_details: r.action_details, 461 - ip_address: r.ip_address, 462 - user_agent: r.user_agent, 463 - created_at: r.created_at, 464 - }) 465 - .collect()) 466 - } 467 - 468 372 async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> { 469 373 let count = sqlx::query_scalar!( 470 374 r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#,
+442 -426
crates/tranquil-oauth-server/src/endpoints/delegation.rs
··· 1 1 use tranquil_pds::auth::{Active, Auth}; 2 2 use tranquil_pds::delegation::DelegationActionType; 3 + use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls}; 3 4 use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit}; 4 5 use tranquil_pds::state::AppState; 5 6 use tranquil_pds::types::PlainPassword; 6 7 use tranquil_pds::util::extract_client_ip; 7 8 use axum::{ 8 9 Json, 9 - extract::State, 10 + extract::{Query, State}, 10 11 http::HeaderMap, 11 - response::{IntoResponse, Response}, 12 + response::{IntoResponse, Redirect, Response}, 12 13 }; 13 14 use serde::{Deserialize, Serialize}; 15 + use tranquil_pds::oauth::RequestData; 16 + use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint}; 14 17 use tranquil_types::{Did, RequestId}; 15 18 19 + #[allow(clippy::result_large_err)] 20 + fn parse_did(s: &str, label: &str) -> Result<Did, Response> { 21 + s.parse() 22 + .map_err(|_| DelegationAuthResponse::err(format!("Invalid {} DID", label))) 23 + } 24 + 25 + async fn get_auth_request(state: &AppState, request_uri: &str) -> Result<RequestData, Response> { 26 + let request_id = RequestId::from(request_uri.to_string()); 27 + match state 28 + .oauth_repo 29 + .get_authorization_request(&request_id) 30 + .await 31 + { 32 + Ok(Some(r)) => Ok(r), 33 + Ok(None) => Err(DelegationAuthResponse::err( 34 + "Authorization request not found", 35 + )), 36 + Err(_) => Err(DelegationAuthResponse::err("Server error")), 37 + } 38 + } 39 + 40 + async fn get_delegation_grant( 41 + state: &AppState, 42 + delegated_did: &Did, 43 + controller_did: &Did, 44 + ) -> Result<tranquil_db_traits::DelegationGrant, Response> { 45 + match state 46 + .delegation_repo 47 + .get_delegation(delegated_did, controller_did) 48 + .await 49 + { 50 + Ok(Some(g)) => Ok(g), 51 + Ok(None) => Err(DelegationAuthResponse::err( 52 + "No delegation grant found for this controller", 53 + )), 54 + Err(_) => Err(DelegationAuthResponse::err("Server error")), 55 + } 56 + } 57 + 58 + async fn finalize_delegation_auth( 59 + state: &AppState, 60 + request_uri: &str, 61 + delegated_did: &Did, 62 + controller_did: &Did, 63 + details: serde_json::Value, 64 + ip: Option<&str>, 65 + user_agent: Option<&str>, 66 + ) -> Response { 67 + let _ = state 68 + .delegation_repo 69 + .log_delegation_action( 70 + delegated_did, 71 + controller_did, 72 + Some(controller_did), 73 + DelegationActionType::TokenIssued, 74 + Some(details), 75 + ip, 76 + user_agent, 77 + ) 78 + .await; 79 + consent_redirect(request_uri) 80 + } 81 + 82 + async fn bind_delegation_to_request( 83 + state: &AppState, 84 + request_uri: &str, 85 + delegated_did: &Did, 86 + controller_did: &Did, 87 + ) -> Result<(), Response> { 88 + let request_id = RequestId::from(request_uri.to_string()); 89 + state 90 + .oauth_repo 91 + .set_request_did(&request_id, delegated_did) 92 + .await 93 + .map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?; 94 + state 95 + .oauth_repo 96 + .set_controller_did(&request_id, controller_did) 97 + .await 98 + .map_err(|_| DelegationAuthResponse::err("Failed to update authorization request"))?; 99 + Ok(()) 100 + } 101 + 102 + fn consent_url(request_uri: &str) -> String { 103 + format!( 104 + "/app/oauth/consent?request_uri={}", 105 + urlencoding::encode(request_uri) 106 + ) 107 + } 108 + 109 + fn consent_redirect(request_uri: &str) -> Response { 110 + DelegationAuthResponse::redirect(consent_url(request_uri)) 111 + } 112 + 16 113 #[derive(Debug, Deserialize)] 17 114 pub struct DelegationAuthSubmit { 18 115 pub request_uri: String, 19 116 pub delegated_did: Option<String>, 20 117 pub controller_did: String, 21 - pub password: PlainPassword, 118 + pub password: Option<PlainPassword>, 22 119 #[serde(default)] 23 120 pub remember_device: bool, 121 + pub auth_method: Option<String>, 122 + } 123 + 124 + enum DelegationAuthResponse { 125 + Redirect(String), 126 + NeedsTotp(String), 127 + Error(String), 128 + TotpError(String), 24 129 } 25 130 26 - #[derive(Debug, Serialize)] 27 - pub struct DelegationAuthResponse { 28 - pub success: bool, 29 - #[serde(skip_serializing_if = "Option::is_none")] 30 - pub needs_totp: Option<bool>, 31 - #[serde(skip_serializing_if = "Option::is_none")] 32 - pub redirect_uri: Option<String>, 33 - #[serde(skip_serializing_if = "Option::is_none")] 34 - pub error: Option<String>, 131 + impl DelegationAuthResponse { 132 + fn err(msg: impl Into<String>) -> Response { 133 + Self::Error(msg.into()).into_response() 134 + } 135 + 136 + fn redirect(uri: impl Into<String>) -> Response { 137 + Self::Redirect(uri.into()).into_response() 138 + } 139 + 140 + fn needs_totp(uri: impl Into<String>) -> Response { 141 + Self::NeedsTotp(uri.into()).into_response() 142 + } 143 + 144 + fn totp_error(msg: impl Into<String>) -> Response { 145 + Self::TotpError(msg.into()).into_response() 146 + } 147 + } 148 + 149 + impl IntoResponse for DelegationAuthResponse { 150 + fn into_response(self) -> Response { 151 + let (success, needs_totp, redirect_uri, error) = match self { 152 + Self::Redirect(uri) => (true, None, Some(uri), None), 153 + Self::NeedsTotp(uri) => (true, Some(true), Some(uri), None), 154 + Self::Error(msg) => (false, None, None, Some(msg)), 155 + Self::TotpError(msg) => (false, Some(true), None, Some(msg)), 156 + }; 157 + 158 + #[derive(Serialize)] 159 + struct Body { 160 + success: bool, 161 + #[serde(skip_serializing_if = "Option::is_none")] 162 + needs_totp: Option<bool>, 163 + #[serde(skip_serializing_if = "Option::is_none")] 164 + redirect_uri: Option<String>, 165 + #[serde(skip_serializing_if = "Option::is_none")] 166 + error: Option<String>, 167 + } 168 + 169 + Json(Body { 170 + success, 171 + needs_totp, 172 + redirect_uri, 173 + error, 174 + }) 175 + .into_response() 176 + } 35 177 } 36 178 37 179 pub async fn delegation_auth( ··· 41 183 Json(form): Json<DelegationAuthSubmit>, 42 184 ) -> Response { 43 185 let client_ip = rate_limit.client_ip(); 44 - let request_id = RequestId::from(form.request_uri.clone()); 45 - let request = match state 46 - .oauth_repo 47 - .get_authorization_request(&request_id) 48 - .await 49 - { 50 - Ok(Some(r)) => r, 51 - Ok(None) => { 52 - return Json(DelegationAuthResponse { 53 - success: false, 54 - needs_totp: None, 55 - redirect_uri: None, 56 - error: Some("Authorization request not found".to_string()), 57 - }) 58 - .into_response(); 59 - } 60 - Err(_) => { 61 - return Json(DelegationAuthResponse { 62 - success: false, 63 - needs_totp: None, 64 - redirect_uri: None, 65 - error: Some("Server error".to_string()), 66 - }) 67 - .into_response(); 68 - } 186 + let request = match get_auth_request(&state, &form.request_uri).await { 187 + Ok(r) => r, 188 + Err(resp) => return resp, 69 189 }; 70 190 71 - let delegated_did: Did = if let Some(did_str) = form.delegated_did.as_ref() { 72 - match did_str.parse() { 191 + let delegated_did = if let Some(did_str) = form.delegated_did.as_ref() { 192 + match parse_did(did_str, "delegated") { 73 193 Ok(d) => d, 74 - Err(_) => { 75 - return Json(DelegationAuthResponse { 76 - success: false, 77 - needs_totp: None, 78 - redirect_uri: None, 79 - error: Some("Invalid delegated DID".to_string()), 80 - }) 81 - .into_response(); 82 - } 194 + Err(resp) => return resp, 83 195 } 84 - } else if let Some(did) = request.did.as_ref() { 85 - did.clone() 196 + } else if let Some(did) = request.did.clone() { 197 + did 86 198 } else { 87 - return Json(DelegationAuthResponse { 88 - success: false, 89 - needs_totp: None, 90 - redirect_uri: None, 91 - error: Some("No delegated account selected".to_string()), 92 - }) 93 - .into_response(); 199 + return DelegationAuthResponse::err("No delegated account selected"); 94 200 }; 95 201 96 - let controller_did: Did = match form.controller_did.parse() { 202 + let controller_did = match parse_did(&form.controller_did, "controller") { 97 203 Ok(d) => d, 98 - Err(_) => { 99 - return Json(DelegationAuthResponse { 100 - success: false, 101 - needs_totp: None, 102 - redirect_uri: None, 103 - error: Some("Invalid controller DID".to_string()), 104 - }) 105 - .into_response(); 106 - } 204 + Err(resp) => return resp, 107 205 }; 108 206 109 - if state 110 - .oauth_repo 111 - .set_request_did(&request_id, &delegated_did) 112 - .await 113 - .is_err() 114 - { 115 - return Json(DelegationAuthResponse { 116 - success: false, 117 - needs_totp: None, 118 - redirect_uri: None, 119 - error: Some("Failed to update authorization request".to_string()), 120 - }) 121 - .into_response(); 122 - } 207 + let grant = match get_delegation_grant(&state, &delegated_did, &controller_did).await { 208 + Ok(g) => g, 209 + Err(resp) => return resp, 210 + }; 123 211 124 - let grant = match state 125 - .delegation_repo 126 - .get_delegation(&delegated_did, &controller_did) 212 + let is_cross_pds = form.auth_method.as_deref() == Some("cross_pds"); 213 + let controller_local = state 214 + .user_repo 215 + .get_auth_info_by_did(&controller_did) 127 216 .await 128 - { 129 - Ok(Some(g)) => g, 130 - Ok(None) => { 131 - return Json(DelegationAuthResponse { 132 - success: false, 133 - needs_totp: None, 134 - redirect_uri: None, 135 - error: Some("No delegation grant found for this controller".to_string()), 136 - }) 137 - .into_response(); 138 - } 139 - Err(_) => { 140 - return Json(DelegationAuthResponse { 141 - success: false, 142 - needs_totp: None, 143 - redirect_uri: None, 144 - error: Some("Server error".to_string()), 145 - }) 146 - .into_response(); 147 - } 148 - }; 217 + .ok() 218 + .flatten(); 219 + 220 + if is_cross_pds || controller_local.is_none() { 221 + let did_doc = match state 222 + .plc_client() 223 + .get_document(controller_did.as_str()) 224 + .await 225 + { 226 + Ok(doc) => doc, 227 + Err(_) => { 228 + return DelegationAuthResponse::err("Failed to resolve controller DID"); 229 + } 230 + }; 149 231 150 - let controller = match state.user_repo.get_auth_info_by_did(&controller_did).await { 151 - Ok(Some(u)) => u, 152 - Ok(None) => { 153 - return Json(DelegationAuthResponse { 154 - success: false, 155 - needs_totp: None, 156 - redirect_uri: None, 157 - error: Some("Controller account not found".to_string()), 158 - }) 159 - .into_response(); 160 - } 161 - Err(_) => { 162 - return Json(DelegationAuthResponse { 163 - success: false, 164 - needs_totp: None, 165 - redirect_uri: None, 166 - error: Some("Server error".to_string()), 167 - }) 168 - .into_response(); 232 + let pds_url = match extract_pds_endpoint(&did_doc) { 233 + Some(url) => url, 234 + None => { 235 + return DelegationAuthResponse::err("Controller has no PDS endpoint"); 236 + } 237 + }; 238 + 239 + let hostname = &tranquil_config::get().server.hostname; 240 + let urls = delegation_oauth_urls(hostname); 241 + let login_hint = extract_handle(&did_doc); 242 + let (par_result, auth_state, oauth_state) = match state 243 + .cross_pds_oauth 244 + .initiate_par( 245 + &pds_url, 246 + &urls, 247 + login_hint.as_deref(), 248 + &form.request_uri, 249 + &controller_did, 250 + &delegated_did, 251 + ) 252 + .await 253 + { 254 + Ok(result) => result, 255 + Err(e) => { 256 + tracing::error!("Cross-PDS PAR failed: {:?}", e); 257 + return DelegationAuthResponse::err("Failed to initiate cross-PDS authentication"); 258 + } 259 + }; 260 + 261 + if let Err(e) = state 262 + .cross_pds_oauth 263 + .store_auth_state(&oauth_state, &auth_state) 264 + .await 265 + { 266 + tracing::error!("Failed to store cross-PDS auth state: {:?}", e); 267 + return DelegationAuthResponse::err( 268 + "Internal error preparing cross-PDS authentication", 269 + ); 169 270 } 170 - }; 271 + 272 + return DelegationAuthResponse::redirect(par_result.authorize_url); 273 + } 274 + 275 + let controller = controller_local.unwrap(); 171 276 172 277 if controller.deactivated_at.is_some() { 173 - return Json(DelegationAuthResponse { 174 - success: false, 175 - needs_totp: None, 176 - redirect_uri: None, 177 - error: Some("Controller account is deactivated".to_string()), 178 - }) 179 - .into_response(); 278 + return DelegationAuthResponse::err("Controller account is deactivated"); 180 279 } 181 280 182 281 if controller.takedown_ref.is_some() { 183 - return Json(DelegationAuthResponse { 184 - success: false, 185 - needs_totp: None, 186 - redirect_uri: None, 187 - error: Some("Controller account has been taken down".to_string()), 188 - }) 189 - .into_response(); 282 + return DelegationAuthResponse::err("Controller account has been taken down"); 190 283 } 191 284 285 + let password = match form.password { 286 + Some(ref pw) => pw, 287 + None => { 288 + return DelegationAuthResponse::err("Password required for local controller"); 289 + } 290 + }; 291 + 192 292 let password_valid = controller 193 293 .password_hash 194 294 .as_ref() 195 - .map(|hash| bcrypt::verify(&form.password, hash).unwrap_or_default()) 295 + .map(|hash| bcrypt::verify(password, hash).unwrap_or_default()) 196 296 .unwrap_or_default(); 197 297 198 298 if !password_valid { 199 - return Json(DelegationAuthResponse { 200 - success: false, 201 - needs_totp: None, 202 - redirect_uri: None, 203 - error: Some("Invalid password".to_string()), 204 - }) 205 - .into_response(); 299 + return DelegationAuthResponse::err("Invalid password"); 206 300 } 207 301 208 - if state 209 - .oauth_repo 210 - .set_controller_did(&request_id, &controller_did) 211 - .await 212 - .is_err() 302 + if let Err(resp) = 303 + bind_delegation_to_request(&state, &form.request_uri, &delegated_did, &controller_did).await 213 304 { 214 - return Json(DelegationAuthResponse { 215 - success: false, 216 - needs_totp: None, 217 - redirect_uri: None, 218 - error: Some("Failed to update authorization request".to_string()), 219 - }) 220 - .into_response(); 305 + return resp; 221 306 } 222 307 223 308 let has_totp = tranquil_api::server::has_totp_enabled(&state, &controller_did).await; 224 309 if has_totp { 225 - return Json(DelegationAuthResponse { 226 - success: true, 227 - needs_totp: Some(true), 228 - redirect_uri: Some(format!( 229 - "/app/oauth/delegation-totp?request_uri={}", 230 - urlencoding::encode(&form.request_uri) 231 - )), 232 - error: None, 233 - }) 234 - .into_response(); 310 + return DelegationAuthResponse::needs_totp(format!( 311 + "/app/oauth/delegation-totp?request_uri={}", 312 + urlencoding::encode(&form.request_uri) 313 + )); 235 314 } 236 315 237 - let user_agent = headers 238 - .get("user-agent") 239 - .and_then(|v| v.to_str().ok()) 240 - .map(|s| s.to_string()); 241 - 242 - let _ = state 243 - .delegation_repo 244 - .log_delegation_action( 245 - &delegated_did, 246 - &controller_did, 247 - Some(&controller_did), 248 - DelegationActionType::TokenIssued, 249 - Some(serde_json::json!({ 250 - "client_id": request.client_id, 251 - "granted_scopes": grant.granted_scopes 252 - })), 253 - Some(client_ip), 254 - user_agent.as_deref(), 255 - ) 256 - .await; 257 - 258 - Json(DelegationAuthResponse { 259 - success: true, 260 - needs_totp: None, 261 - redirect_uri: Some(format!( 262 - "/app/oauth/consent?request_uri={}", 263 - urlencoding::encode(&form.request_uri) 264 - )), 265 - error: None, 266 - }) 267 - .into_response() 316 + let user_agent = tranquil_pds::util::extract_user_agent(&headers); 317 + 318 + finalize_delegation_auth( 319 + &state, 320 + &form.request_uri, 321 + &delegated_did, 322 + &controller_did, 323 + serde_json::json!({ 324 + "client_id": request.client_id, 325 + "granted_scopes": grant.granted_scopes 326 + }), 327 + Some(client_ip), 328 + user_agent.as_deref(), 329 + ) 330 + .await 268 331 } 269 332 270 333 #[derive(Debug, Deserialize)] ··· 280 343 Json(form): Json<DelegationTotpSubmit>, 281 344 ) -> Response { 282 345 let client_ip = rate_limit.client_ip(); 283 - let totp_request_id = RequestId::from(form.request_uri.clone()); 284 - let request = match state 285 - .oauth_repo 286 - .get_authorization_request(&totp_request_id) 287 - .await 288 - { 289 - Ok(Some(r)) => r, 290 - Ok(None) => { 291 - return Json(DelegationAuthResponse { 292 - success: false, 293 - needs_totp: None, 294 - redirect_uri: None, 295 - error: Some("Authorization request not found".to_string()), 296 - }) 297 - .into_response(); 298 - } 299 - Err(_) => { 300 - return Json(DelegationAuthResponse { 301 - success: false, 302 - needs_totp: None, 303 - redirect_uri: None, 304 - error: Some("Server error".to_string()), 305 - }) 306 - .into_response(); 307 - } 346 + let request = match get_auth_request(&state, &form.request_uri).await { 347 + Ok(r) => r, 348 + Err(resp) => return resp, 308 349 }; 309 350 310 - let controller_did_str = match &request.controller_did { 311 - Some(did) => did.clone(), 312 - None => { 313 - return Json(DelegationAuthResponse { 314 - success: false, 315 - needs_totp: None, 316 - redirect_uri: None, 317 - error: Some("Controller not authenticated".to_string()), 318 - }) 319 - .into_response(); 320 - } 351 + let controller_did = match request.controller_did { 352 + Some(did) => did, 353 + None => return DelegationAuthResponse::err("Controller not authenticated"), 321 354 }; 322 355 323 - let controller_did: Did = match controller_did_str.parse() { 324 - Ok(d) => d, 325 - Err(_) => { 326 - return Json(DelegationAuthResponse { 327 - success: false, 328 - needs_totp: None, 329 - redirect_uri: None, 330 - error: Some("Invalid controller DID".to_string()), 331 - }) 332 - .into_response(); 333 - } 356 + let delegated_did = match request.did { 357 + Some(did) => did, 358 + None => return DelegationAuthResponse::err("No delegated account"), 334 359 }; 335 360 336 - let delegated_did_str = match &request.did { 337 - Some(did) => did.clone(), 338 - None => { 339 - return Json(DelegationAuthResponse { 340 - success: false, 341 - needs_totp: None, 342 - redirect_uri: None, 343 - error: Some("No delegated account".to_string()), 344 - }) 345 - .into_response(); 346 - } 347 - }; 348 - 349 - let delegated_did: Did = match delegated_did_str.parse() { 350 - Ok(d) => d, 351 - Err(_) => { 352 - return Json(DelegationAuthResponse { 353 - success: false, 354 - needs_totp: None, 355 - redirect_uri: None, 356 - error: Some("Invalid delegated DID".to_string()), 357 - }) 358 - .into_response(); 359 - } 360 - }; 361 - 362 - let grant = match state 363 - .delegation_repo 364 - .get_delegation(&delegated_did, &controller_did) 365 - .await 366 - { 367 - Ok(Some(g)) => g, 368 - _ => { 369 - return Json(DelegationAuthResponse { 370 - success: false, 371 - needs_totp: None, 372 - redirect_uri: None, 373 - error: Some("Delegation grant not found".to_string()), 374 - }) 375 - .into_response(); 376 - } 361 + let grant = match get_delegation_grant(&state, &delegated_did, &controller_did).await { 362 + Ok(g) => g, 363 + Err(resp) => return resp, 377 364 }; 378 365 379 366 let totp_valid = 380 367 tranquil_api::server::verify_totp_or_backup_for_user(&state, &controller_did, &form.code) 381 368 .await; 382 369 if !totp_valid { 383 - return Json(DelegationAuthResponse { 384 - success: false, 385 - needs_totp: Some(true), 386 - redirect_uri: None, 387 - error: Some("Invalid TOTP code".to_string()), 388 - }) 389 - .into_response(); 370 + return DelegationAuthResponse::totp_error("Invalid TOTP code"); 390 371 } 391 372 392 - let user_agent = headers 393 - .get("user-agent") 394 - .and_then(|v| v.to_str().ok()) 395 - .map(|s| s.to_string()); 396 - 397 - let _ = state 398 - .delegation_repo 399 - .log_delegation_action( 400 - &delegated_did, 401 - &controller_did, 402 - Some(&controller_did), 403 - DelegationActionType::TokenIssued, 404 - Some(serde_json::json!({ 405 - "client_id": request.client_id, 406 - "granted_scopes": grant.granted_scopes 407 - })), 408 - Some(client_ip), 409 - user_agent.as_deref(), 410 - ) 411 - .await; 412 - 413 - Json(DelegationAuthResponse { 414 - success: true, 415 - needs_totp: None, 416 - redirect_uri: Some(format!( 417 - "/app/oauth/consent?request_uri={}", 418 - urlencoding::encode(&form.request_uri) 419 - )), 420 - error: None, 421 - }) 422 - .into_response() 373 + let user_agent = tranquil_pds::util::extract_user_agent(&headers); 374 + 375 + finalize_delegation_auth( 376 + &state, 377 + &form.request_uri, 378 + &delegated_did, 379 + &controller_did, 380 + serde_json::json!({ 381 + "client_id": request.client_id, 382 + "granted_scopes": grant.granted_scopes 383 + }), 384 + Some(client_ip), 385 + user_agent.as_deref(), 386 + ) 387 + .await 423 388 } 424 389 425 390 #[derive(Debug, Deserialize)] ··· 436 401 ) -> Response { 437 402 let controller_did = &auth.did; 438 403 439 - let delegated_did: Did = match form.delegated_did.parse() { 404 + let delegated_did = match parse_did(&form.delegated_did, "delegated") { 440 405 Ok(d) => d, 441 - Err(_) => { 442 - return Json(DelegationAuthResponse { 443 - success: false, 444 - needs_totp: None, 445 - redirect_uri: None, 446 - error: Some("Invalid delegated DID".to_string()), 447 - }) 448 - .into_response(); 449 - } 406 + Err(resp) => return resp, 450 407 }; 451 408 452 - let request_id = RequestId::from(form.request_uri.clone()); 453 - let request = match state 454 - .oauth_repo 455 - .get_authorization_request(&request_id) 409 + let request = match get_auth_request(&state, &form.request_uri).await { 410 + Ok(r) => r, 411 + Err(resp) => return resp, 412 + }; 413 + 414 + let grant = match get_delegation_grant(&state, &delegated_did, controller_did).await { 415 + Ok(g) => g, 416 + Err(resp) => return resp, 417 + }; 418 + 419 + if let Err(resp) = 420 + bind_delegation_to_request(&state, &form.request_uri, &delegated_did, controller_did).await 421 + { 422 + return resp; 423 + } 424 + 425 + let ip = extract_client_ip(&headers, None); 426 + let user_agent = tranquil_pds::util::extract_user_agent(&headers); 427 + 428 + finalize_delegation_auth( 429 + &state, 430 + &form.request_uri, 431 + &delegated_did, 432 + controller_did, 433 + serde_json::json!({ 434 + "client_id": request.client_id, 435 + "granted_scopes": grant.granted_scopes, 436 + "auth_method": "token" 437 + }), 438 + Some(&ip), 439 + user_agent.as_deref(), 440 + ) 441 + .await 442 + } 443 + 444 + #[derive(Debug, Deserialize)] 445 + pub struct CrossPdsCallbackParams { 446 + pub code: String, 447 + pub state: String, 448 + pub iss: Option<String>, 449 + } 450 + 451 + pub async fn delegation_callback( 452 + State(state): State<AppState>, 453 + _rate_limit: OAuthRateLimited<LoginLimit>, 454 + Query(params): Query<CrossPdsCallbackParams>, 455 + ) -> Response { 456 + let auth_state = match state 457 + .cross_pds_oauth 458 + .retrieve_auth_state(&params.state) 456 459 .await 457 460 { 458 - Ok(Some(r)) => r, 459 - Ok(None) => { 460 - return Json(DelegationAuthResponse { 461 - success: false, 462 - needs_totp: None, 463 - redirect_uri: None, 464 - error: Some("Authorization request not found".to_string()), 465 - }) 466 - .into_response(); 467 - } 468 - Err(_) => { 469 - return Json(DelegationAuthResponse { 470 - success: false, 471 - needs_totp: None, 472 - redirect_uri: None, 473 - error: Some("Server error".to_string()), 474 - }) 475 - .into_response(); 461 + Ok(s) => s, 462 + Err(e) => { 463 + tracing::error!("Failed to retrieve cross-PDS auth state: {:?}", e); 464 + return ( 465 + axum::http::StatusCode::BAD_REQUEST, 466 + "Cross-PDS auth state expired or invalid", 467 + ) 468 + .into_response(); 476 469 } 477 470 }; 478 471 479 - let grant = match state 480 - .delegation_repo 481 - .get_delegation(&delegated_did, controller_did) 472 + if let Some(ref expected_issuer) = auth_state.expected_issuer { 473 + match &params.iss { 474 + Some(iss) if iss != expected_issuer => { 475 + tracing::error!( 476 + "Cross-PDS issuer mismatch: expected {}, got {}", 477 + expected_issuer, 478 + iss 479 + ); 480 + return ( 481 + axum::http::StatusCode::FORBIDDEN, 482 + "Authorization server issuer mismatch", 483 + ) 484 + .into_response(); 485 + } 486 + None => { 487 + tracing::error!( 488 + "Cross-PDS callback missing iss parameter (expected {}), possible mix-up attack", 489 + expected_issuer 490 + ); 491 + return ( 492 + axum::http::StatusCode::BAD_REQUEST, 493 + "Missing required iss parameter", 494 + ) 495 + .into_response(); 496 + } 497 + _ => {} 498 + } 499 + } 500 + 501 + let hostname = &tranquil_config::get().server.hostname; 502 + let urls = delegation_oauth_urls(hostname); 503 + 504 + let returned_sub = match state 505 + .cross_pds_oauth 506 + .exchange_code( 507 + &auth_state, 508 + &params.code, 509 + &urls.client_id, 510 + &urls.redirect_uri, 511 + ) 482 512 .await 483 513 { 484 - Ok(Some(g)) => g, 485 - Ok(None) => { 486 - return Json(DelegationAuthResponse { 487 - success: false, 488 - needs_totp: None, 489 - redirect_uri: None, 490 - error: Some("No delegation grant found for this controller".to_string()), 491 - }) 492 - .into_response(); 493 - } 494 - Err(_) => { 495 - return Json(DelegationAuthResponse { 496 - success: false, 497 - needs_totp: None, 498 - redirect_uri: None, 499 - error: Some("Server error".to_string()), 500 - }) 501 - .into_response(); 514 + Ok(sub) => sub, 515 + Err(e) => { 516 + tracing::error!("Cross-PDS token exchange failed: {:?}", e); 517 + return ( 518 + axum::http::StatusCode::BAD_GATEWAY, 519 + "Controller authentication failed", 520 + ) 521 + .into_response(); 502 522 } 503 523 }; 504 524 505 - if state 506 - .oauth_repo 507 - .set_request_did(&request_id, &delegated_did) 508 - .await 509 - .is_err() 510 - { 511 - return Json(DelegationAuthResponse { 512 - success: false, 513 - needs_totp: None, 514 - redirect_uri: None, 515 - error: Some("Failed to update authorization request".to_string()), 516 - }) 517 - .into_response(); 525 + if returned_sub != auth_state.controller_did.as_str() { 526 + tracing::error!( 527 + "Cross-PDS DID mismatch: expected {}, got {}", 528 + auth_state.controller_did, 529 + returned_sub 530 + ); 531 + return (axum::http::StatusCode::FORBIDDEN, "Controller DID mismatch").into_response(); 518 532 } 519 533 520 - if state 521 - .oauth_repo 522 - .set_controller_did(&request_id, controller_did) 523 - .await 524 - .is_err() 525 - { 526 - return Json(DelegationAuthResponse { 527 - success: false, 528 - needs_totp: None, 529 - redirect_uri: None, 530 - error: Some("Failed to update authorization request".to_string()), 531 - }) 532 - .into_response(); 534 + let delegated_did = &auth_state.delegated_did; 535 + let controller_did = &auth_state.controller_did; 536 + 537 + if let Err(_) = get_delegation_grant(&state, delegated_did, controller_did).await { 538 + tracing::warn!( 539 + "Delegation grant revoked during cross-PDS auth: {} -> {}", 540 + controller_did, 541 + delegated_did 542 + ); 543 + return ( 544 + axum::http::StatusCode::FORBIDDEN, 545 + "Delegation grant has been revoked", 546 + ) 547 + .into_response(); 533 548 } 534 549 535 - let ip = extract_client_ip(&headers, None); 536 - let user_agent = headers 537 - .get("user-agent") 538 - .and_then(|v| v.to_str().ok()) 539 - .map(|s| s.to_string()); 550 + if let Err(resp) = bind_delegation_to_request( 551 + &state, 552 + &auth_state.original_request_uri, 553 + delegated_did, 554 + controller_did, 555 + ) 556 + .await 557 + { 558 + return resp; 559 + } 540 560 541 561 let _ = state 542 562 .delegation_repo 543 563 .log_delegation_action( 544 - &delegated_did, 564 + delegated_did, 545 565 controller_did, 546 566 Some(controller_did), 547 567 DelegationActionType::TokenIssued, 548 568 Some(serde_json::json!({ 549 - "client_id": request.client_id, 550 - "granted_scopes": grant.granted_scopes, 551 - "auth_method": "token" 569 + "auth_method": "cross_pds", 570 + "controller_pds": auth_state.controller_pds_url 552 571 })), 553 - Some(&ip), 554 - user_agent.as_deref(), 572 + None, 573 + None, 555 574 ) 556 575 .await; 557 576 558 - Json(DelegationAuthResponse { 559 - success: true, 560 - needs_totp: None, 561 - redirect_uri: Some(format!( 562 - "/app/oauth/consent?request_uri={}", 563 - urlencoding::encode(&form.request_uri) 564 - )), 565 - error: None, 566 - }) 567 - .into_response() 577 + Redirect::temporary(&consent_url(&auth_state.original_request_uri)).into_response() 578 + } 579 + 580 + pub async fn delegation_client_metadata(State(_state): State<AppState>) -> Response { 581 + let hostname = &tranquil_config::get().server.hostname; 582 + let metadata = build_client_metadata(hostname); 583 + Json(metadata).into_response() 568 584 }
+2 -5
crates/tranquil-oauth-server/src/endpoints/token/helpers.rs
··· 4 4 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 5 5 use chrono::Utc; 6 6 use hmac::Mac; 7 - use sha2::{Digest, Sha256}; 7 + use sha2::Sha256; 8 8 use subtle::ConstantTimeEq; 9 9 10 10 const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300; ··· 17 17 } 18 18 19 19 pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> { 20 - let mut hasher = Sha256::new(); 21 - hasher.update(code_verifier.as_bytes()); 22 - let hash = hasher.finalize(); 23 - let computed_challenge = URL_SAFE_NO_PAD.encode(hash); 20 + let computed_challenge = tranquil_pds::oauth::compute_pkce_challenge(code_verifier); 24 21 if !bool::from( 25 22 computed_challenge 26 23 .as_bytes()
+8
crates/tranquil-oauth-server/src/lib.rs
··· 65 65 "/delegation/totp", 66 66 post(endpoints::delegation_totp_verify), 67 67 ) 68 + .route( 69 + "/delegation/callback", 70 + get(endpoints::delegation_callback), 71 + ) 72 + .route( 73 + "/delegation/client-metadata", 74 + get(endpoints::delegation_client_metadata), 75 + ) 68 76 .route("/token", post(endpoints::token_endpoint)) 69 77 .route("/revoke", post(endpoints::revoke_token)) 70 78 .route("/introspect", post(endpoints::introspect_token))
+2 -9
crates/tranquil-oauth-server/src/sso_endpoints.rs
··· 19 19 }; 20 20 use tranquil_pds::state::AppState; 21 21 22 - fn generate_state() -> String { 23 - use rand::RngCore; 24 - let mut bytes = [0u8; 32]; 25 - rand::thread_rng().fill_bytes(&mut bytes); 26 - URL_SAFE_NO_PAD.encode(bytes) 27 - } 28 - 29 22 fn generate_nonce() -> String { 30 23 use rand::RngCore; 31 24 let mut bytes = [0u8; 16]; ··· 129 122 } 130 123 }; 131 124 132 - let sso_state = generate_state(); 125 + let sso_state = tranquil_pds::util::generate_random_token(); 133 126 let nonce = generate_nonce(); 134 127 let redirect_uri = SsoConfig::get_redirect_uri(); 135 128 ··· 1323 1316 refresh_expires_at: refresh_meta.expires_at, 1324 1317 login_type: tranquil_db_traits::LoginType::Modern, 1325 1318 mfa_verified: false, 1326 - scope: None, 1319 + scope: Some("transition:generic".to_string()), 1327 1320 controller_did: None, 1328 1321 app_password_name: None, 1329 1322 };
+81
crates/tranquil-oauth/src/dpop.rs
··· 385 385 URL_SAFE_NO_PAD.encode(hash) 386 386 } 387 387 388 + pub fn compute_pkce_challenge(verifier: &str) -> String { 389 + let mut hasher = Sha256::new(); 390 + hasher.update(verifier.as_bytes()); 391 + URL_SAFE_NO_PAD.encode(hasher.finalize()) 392 + } 393 + 394 + pub fn es256_signing_key_to_jwk(key: &p256::ecdsa::SigningKey) -> Result<DPoPJwk, OAuthError> { 395 + let point = key.verifying_key().to_encoded_point(false); 396 + let x = URL_SAFE_NO_PAD.encode( 397 + point 398 + .x() 399 + .ok_or_else(|| OAuthError::InvalidDpopProof("invalid EC key: missing x".into()))?, 400 + ); 401 + let y = URL_SAFE_NO_PAD.encode( 402 + point 403 + .y() 404 + .ok_or_else(|| OAuthError::InvalidDpopProof("invalid EC key: missing y".into()))?, 405 + ); 406 + Ok(DPoPJwk { 407 + kty: "EC".to_string(), 408 + crv: Some("P-256".to_string()), 409 + x: Some(x), 410 + y: Some(y), 411 + }) 412 + } 413 + 414 + pub fn create_dpop_proof( 415 + signing_key: &p256::ecdsa::SigningKey, 416 + method: &str, 417 + url: &str, 418 + nonce: Option<&str>, 419 + access_token_hash: Option<&str>, 420 + ) -> Result<String, OAuthError> { 421 + use p256::ecdsa::signature::Signer; 422 + 423 + let jwk = es256_signing_key_to_jwk(signing_key)?; 424 + 425 + let header = serde_json::json!({ 426 + "typ": "dpop+jwt", 427 + "alg": "ES256", 428 + "jwk": jwk 429 + }); 430 + 431 + let jti = { 432 + use rand::Rng; 433 + let bytes: [u8; 16] = rand::thread_rng().r#gen(); 434 + URL_SAFE_NO_PAD.encode(bytes) 435 + }; 436 + 437 + let mut payload = serde_json::json!({ 438 + "jti": jti, 439 + "htm": method, 440 + "htu": url, 441 + "iat": Utc::now().timestamp() 442 + }); 443 + if let Some(n) = nonce { 444 + payload["nonce"] = serde_json::Value::String(n.to_string()); 445 + } 446 + if let Some(ath) = access_token_hash { 447 + payload["ath"] = serde_json::Value::String(ath.to_string()); 448 + } 449 + 450 + let header_b64 = URL_SAFE_NO_PAD.encode( 451 + serde_json::to_vec(&header).map_err(|e| OAuthError::InvalidDpopProof(e.to_string()))?, 452 + ); 453 + let payload_b64 = URL_SAFE_NO_PAD.encode( 454 + serde_json::to_vec(&payload).map_err(|e| OAuthError::InvalidDpopProof(e.to_string()))?, 455 + ); 456 + 457 + let signing_input = format!("{}.{}", header_b64, payload_b64); 458 + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 459 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 460 + 461 + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) 462 + } 463 + 464 + pub fn compute_es256_jkt(signing_key: &p256::ecdsa::SigningKey) -> Result<String, OAuthError> { 465 + let jwk = es256_signing_key_to_jwk(signing_key)?; 466 + compute_jwk_thumbprint(&jwk) 467 + } 468 + 388 469 #[cfg(test)] 389 470 mod tests { 390 471 use super::*;
+3 -1
crates/tranquil-oauth/src/lib.rs
··· 6 6 pub use client::{ClientMetadata, ClientMetadataCache, verify_client_auth}; 7 7 pub use dpop::{ 8 8 DPoPJwk, DPoPProofHeader, DPoPProofPayload, DPoPVerifier, DPoPVerifyResult, 9 - compute_access_token_hash, compute_jwk_thumbprint, 9 + compute_access_token_hash, compute_es256_jkt, compute_jwk_thumbprint, compute_pkce_challenge, 10 + create_dpop_proof, 11 + es256_signing_key_to_jwk, 10 12 }; 11 13 pub use error::OAuthError; 12 14 pub use types::{
+25
crates/tranquil-pds/src/api/validation.rs
··· 308 308 validate_service_handle(handle, ReservedHandlePolicy::Reject) 309 309 } 310 310 311 + pub fn resolve_handle_input(input: &str) -> Result<String, HandleValidationError> { 312 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 313 + let matched_domain = available_domains 314 + .iter() 315 + .filter(|d| input.ends_with(&format!(".{}", d))) 316 + .max_by_key(|d| d.len()); 317 + 318 + if !input.contains('.') || matched_domain.is_some() { 319 + let handle_to_validate = match matched_domain { 320 + Some(domain) => input 321 + .strip_suffix(&format!(".{}", domain)) 322 + .unwrap_or(input), 323 + None => input, 324 + }; 325 + let validated = validate_short_handle(handle_to_validate)?; 326 + Ok(format!( 327 + "{}.{}", 328 + validated, 329 + matched_domain.unwrap_or(&available_domains[0]) 330 + )) 331 + } else { 332 + validate_full_domain_handle(input) 333 + } 334 + } 335 + 311 336 pub fn validate_service_handle( 312 337 handle: &str, 313 338 reserved_policy: ReservedHandlePolicy,
+1 -1
crates/tranquil-pds/src/auth/mod.rs
··· 206 206 return ScopePermissions::from_scope_string(Some(scope)); 207 207 } 208 208 if !self.is_oauth() { 209 - return ScopePermissions::from_scope_string(Some("atproto")); 209 + return ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky")); 210 210 } 211 211 ScopePermissions::from_scope_string(self.scope.as_deref()) 212 212 }
+42 -3
crates/tranquil-pds/src/delegation/mod.rs
··· 2 2 pub mod scopes; 3 3 4 4 pub use roles::{ 5 - CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers, 6 - verify_can_be_controller, verify_can_control_accounts, 5 + CanAddControllers, CanControlAccounts, verify_can_add_controllers, 6 + verify_can_control_accounts, 7 7 }; 8 8 pub use scopes::{ 9 9 InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, 10 - intersect_scopes, validate_delegation_scopes, 10 + intersect_scopes, 11 11 }; 12 12 pub use tranquil_db_traits::DelegationActionType; 13 + 14 + use crate::state::AppState; 15 + use crate::types::Did; 16 + 17 + #[derive(serde::Serialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct ResolvedIdentity { 20 + pub did: Did, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub handle: Option<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub pds_url: Option<String>, 25 + pub is_local: bool, 26 + } 27 + 28 + pub async fn resolve_identity(state: &AppState, did: &Did) -> Option<ResolvedIdentity> { 29 + let is_local = state 30 + .user_repo 31 + .get_by_did(did) 32 + .await 33 + .ok() 34 + .flatten() 35 + .is_some(); 36 + 37 + let did_doc = state 38 + .did_resolver 39 + .resolve_did_document(did.as_str()) 40 + .await?; 41 + 42 + let pds_url = tranquil_types::did_doc::extract_pds_endpoint(&did_doc); 43 + let handle = tranquil_types::did_doc::extract_handle(&did_doc); 44 + 45 + Some(ResolvedIdentity { 46 + did: did.clone(), 47 + handle, 48 + pds_url, 49 + is_local, 50 + }) 51 + }
+50 -74
crates/tranquil-pds/src/delegation/roles.rs
··· 1 + use std::marker::PhantomData; 2 + 1 3 use axum::response::{IntoResponse, Response}; 2 4 3 5 use crate::api::error::ApiError; ··· 5 7 use crate::state::AppState; 6 8 use crate::types::Did; 7 9 8 - pub struct CanAddControllers<'a> { 9 - user: &'a AuthenticatedUser, 10 - } 10 + pub struct AddControllersTag; 11 + pub struct ControlAccountsTag; 11 12 12 - pub struct CanControlAccounts<'a> { 13 + pub struct DelegationProof<'a, Tag> { 13 14 user: &'a AuthenticatedUser, 15 + _tag: PhantomData<Tag>, 14 16 } 15 17 16 - pub struct CanBeController<'a> { 17 - controller_did: &'a Did, 18 - } 19 - 20 - impl<'a> CanAddControllers<'a> { 21 - pub fn did(&self) -> &Did { 22 - &self.user.did 23 - } 24 - 25 - pub fn user(&self) -> &AuthenticatedUser { 26 - self.user 27 - } 28 - } 18 + pub type CanAddControllers<'a> = DelegationProof<'a, AddControllersTag>; 19 + pub type CanControlAccounts<'a> = DelegationProof<'a, ControlAccountsTag>; 29 20 30 - impl<'a> CanControlAccounts<'a> { 21 + impl<'a, Tag> DelegationProof<'a, Tag> { 31 22 pub fn did(&self) -> &Did { 32 23 &self.user.did 33 24 } 34 - 35 - pub fn user(&self) -> &AuthenticatedUser { 36 - self.user 37 - } 38 - } 39 - 40 - impl<'a> CanBeController<'a> { 41 - pub fn did(&self) -> &Did { 42 - self.controller_did 43 - } 44 25 } 45 26 46 - pub async fn verify_can_add_controllers<'a>( 27 + async fn check_delegation_flag( 47 28 state: &AppState, 48 - user: &'a AuthenticatedUser, 49 - ) -> Result<CanAddControllers<'a>, Response> { 50 - match state.delegation_repo.controls_any_accounts(&user.did).await { 51 - Ok(true) => Err(ApiError::InvalidDelegation( 52 - "Cannot add controllers to an account that controls other accounts".into(), 53 - ) 54 - .into_response()), 55 - Ok(false) => Ok(CanAddControllers { user }), 29 + did: &Did, 30 + check_is_delegated: bool, 31 + error_msg: &str, 32 + ) -> Result<bool, Response> { 33 + let result = if check_is_delegated { 34 + state.delegation_repo.is_delegated_account(did).await 35 + } else { 36 + state.delegation_repo.controls_any_accounts(did).await 37 + }; 38 + match result { 39 + Ok(true) => Err(ApiError::InvalidDelegation(error_msg.into()).into_response()), 40 + Ok(false) => Ok(false), 56 41 Err(e) => { 57 42 tracing::error!("Failed to check delegation status: {:?}", e); 58 43 Err( ··· 63 48 } 64 49 } 65 50 66 - pub async fn verify_can_control_accounts<'a>( 51 + pub async fn verify_can_add_controllers<'a>( 67 52 state: &AppState, 68 53 user: &'a AuthenticatedUser, 69 - ) -> Result<CanControlAccounts<'a>, Response> { 70 - match state.delegation_repo.has_any_controllers(&user.did).await { 71 - Ok(true) => Err(ApiError::InvalidDelegation( 72 - "Cannot create delegated accounts from a controlled account".into(), 73 - ) 74 - .into_response()), 75 - Ok(false) => Ok(CanControlAccounts { user }), 76 - Err(e) => { 77 - tracing::error!("Failed to check controller status: {:?}", e); 78 - Err( 79 - ApiError::InternalError(Some("Failed to verify controller status".into())) 80 - .into_response(), 81 - ) 82 - } 83 - } 54 + ) -> Result<CanAddControllers<'a>, Response> { 55 + check_delegation_flag( 56 + state, 57 + &user.did, 58 + false, 59 + "Cannot add controllers to an account that controls other accounts", 60 + ) 61 + .await?; 62 + Ok(DelegationProof { 63 + user, 64 + _tag: PhantomData, 65 + }) 84 66 } 85 67 86 - pub async fn verify_can_be_controller<'a>( 68 + pub async fn verify_can_control_accounts<'a>( 87 69 state: &AppState, 88 - controller_did: &'a Did, 89 - ) -> Result<CanBeController<'a>, Response> { 90 - match state 91 - .delegation_repo 92 - .has_any_controllers(controller_did) 93 - .await 94 - { 95 - Ok(true) => Err(ApiError::InvalidDelegation( 96 - "Cannot add a controlled account as a controller".into(), 97 - ) 98 - .into_response()), 99 - Ok(false) => Ok(CanBeController { controller_did }), 100 - Err(e) => { 101 - tracing::error!("Failed to check controller status: {:?}", e); 102 - Err( 103 - ApiError::InternalError(Some("Failed to verify controller status".into())) 104 - .into_response(), 105 - ) 106 - } 107 - } 70 + user: &'a AuthenticatedUser, 71 + ) -> Result<CanControlAccounts<'a>, Response> { 72 + check_delegation_flag( 73 + state, 74 + &user.did, 75 + true, 76 + "Cannot create delegated accounts from a controlled account", 77 + ) 78 + .await?; 79 + Ok(DelegationProof { 80 + user, 81 + _tag: PhantomData, 82 + }) 108 83 } 84 +
+141 -32
crates/tranquil-pds/src/delegation/scopes.rs
··· 4 4 DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError, 5 5 }; 6 6 7 + #[derive(Debug, serde::Serialize)] 7 8 pub struct ScopePreset { 8 9 pub name: &'static str, 9 10 pub label: &'static str, ··· 50 51 let requested_has_atproto = requested_set.contains("atproto"); 51 52 52 53 if granted_has_atproto { 53 - return requested_set.into_iter().collect::<Vec<_>>().join(" "); 54 + let mut scopes: Vec<&str> = requested_set.into_iter().collect(); 55 + scopes.sort(); 56 + return scopes.join(" "); 54 57 } 55 58 56 59 if requested_has_atproto { 57 - return granted_set.into_iter().collect::<Vec<_>>().join(" "); 60 + let mut scopes: Vec<&str> = granted_set.into_iter().collect(); 61 + scopes.sort(); 62 + return scopes.join(" "); 58 63 } 59 64 60 65 let mut result: Vec<&str> = requested_set 61 66 .iter() 62 - .filter_map(|requested_scope| { 63 - if granted_set.contains(requested_scope) { 64 - Some(*requested_scope) 65 - } else { 66 - find_matching_scope(requested_scope, &granted_set) 67 - } 68 - }) 67 + .filter(|requested_scope| any_granted_covers(requested_scope, &granted_set)) 68 + .copied() 69 69 .collect(); 70 70 71 71 result.sort(); 72 72 result.join(" ") 73 73 } 74 74 75 - fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> { 75 + fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool { 76 76 granted 77 77 .iter() 78 - .find(|&granted_scope| scopes_compatible(granted_scope, requested)) 79 - .map(|v| v as _) 78 + .any(|granted_scope| scope_covers(granted_scope, requested)) 80 79 } 81 80 82 - fn scopes_compatible(granted: &str, requested: &str) -> bool { 81 + fn scope_covers(granted: &str, requested: &str) -> bool { 83 82 if granted == requested { 84 83 return true; 85 84 } 86 85 87 - let (granted_base, _granted_params) = split_scope(granted); 88 - let (requested_base, _requested_params) = split_scope(requested); 86 + let (granted_base, granted_params) = split_scope(granted); 87 + let (requested_base, requested_params) = split_scope(requested); 89 88 90 - if granted_base.ends_with(":*") 89 + let base_matches = if granted_base.ends_with(":*") 91 90 && requested_base.starts_with(&granted_base[..granted_base.len() - 1]) 92 91 { 93 - return true; 94 - } 95 - 96 - if let Some(prefix) = granted_base.strip_suffix(".*") 92 + true 93 + } else if let Some(prefix) = granted_base.strip_suffix(".*") 97 94 && requested_base.starts_with(prefix) 98 95 && requested_base.len() > prefix.len() 99 96 { 100 - return true; 97 + true 98 + } else { 99 + granted_base == requested_base 100 + }; 101 + 102 + if !base_matches { 103 + return false; 101 104 } 102 105 103 - false 106 + match (granted_params, requested_params) { 107 + (None, _) => true, 108 + (Some(_), None) => true, 109 + (Some(gp), Some(rp)) => params_cover(gp, rp), 110 + } 111 + } 112 + 113 + fn params_cover(granted_params: &str, requested_params: &str) -> bool { 114 + let granted_kv: HashSet<(&str, &str)> = granted_params 115 + .split('&') 116 + .filter_map(|pair| pair.split_once('=')) 117 + .collect(); 118 + let requested_kv: HashSet<(&str, &str)> = requested_params 119 + .split('&') 120 + .filter_map(|pair| pair.split_once('=')) 121 + .collect(); 122 + 123 + let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect(); 124 + let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect(); 125 + 126 + requested_keys.iter().all(|key| { 127 + if !granted_keys.contains(key) { 128 + return false; 129 + } 130 + let requested_values: HashSet<&str> = requested_kv 131 + .iter() 132 + .filter(|(k, _)| k == key) 133 + .map(|(_, v)| *v) 134 + .collect(); 135 + let granted_values: HashSet<&str> = granted_kv 136 + .iter() 137 + .filter(|(k, _)| k == key) 138 + .map(|(_, v)| *v) 139 + .collect(); 140 + requested_values.is_subset(&granted_values) 141 + }) 104 142 } 105 143 106 144 fn split_scope(scope: &str) -> (&str, Option<&str>) { ··· 111 149 } 112 150 } 113 151 114 - pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> { 115 - ValidatedDelegationScope::new(scopes)?; 116 - Ok(()) 117 - } 118 - 119 152 #[cfg(test)] 120 153 mod tests { 121 154 use super::*; ··· 152 185 assert_eq!(intersect_scopes("atproto", ""), ""); 153 186 } 154 187 188 + #[test] 189 + fn test_intersect_returns_requested_not_granted() { 190 + let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*"); 191 + assert_eq!(result, "repo:app.bsky.feed.post?action=create"); 192 + } 193 + 194 + #[test] 195 + fn test_intersect_wildcard_granted_covers_specific_requested() { 196 + let result = intersect_scopes( 197 + "repo:app.bsky.feed.post?action=create", 198 + "repo:*?action=create repo:*?action=update blob:*/*", 199 + ); 200 + assert_eq!(result, "repo:app.bsky.feed.post?action=create"); 201 + } 202 + 203 + #[test] 204 + fn test_intersect_mismatched_params_rejects() { 205 + let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete"); 206 + assert!(result.is_empty()); 207 + } 208 + 209 + #[test] 210 + fn test_intersect_granted_no_params_covers_requested_with_params() { 211 + let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*"); 212 + assert_eq!(result, "repo:app.bsky.feed.post?action=create"); 213 + } 214 + 215 + #[test] 216 + fn test_intersect_granted_with_params_covers_requested_no_params() { 217 + let result = 218 + intersect_scopes("repo:app.bsky.feed.post", "repo:*?action=create&action=delete"); 219 + assert_eq!(result, "repo:app.bsky.feed.post"); 220 + } 221 + 222 + #[test] 223 + fn test_intersect_multi_action_subset() { 224 + let result = intersect_scopes( 225 + "repo:*?action=create", 226 + "repo:*?action=create&action=update&action=delete", 227 + ); 228 + assert_eq!(result, "repo:*?action=create"); 229 + } 230 + 231 + #[test] 232 + fn test_scope_covers_base_only() { 233 + assert!(scope_covers("repo:*", "repo:app.bsky.feed.post")); 234 + assert!(scope_covers("repo:*", "repo:app.bsky.feed.post?action=create")); 235 + assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post")); 236 + } 237 + 238 + #[test] 239 + fn test_scope_covers_params() { 240 + assert!(scope_covers( 241 + "repo:*?action=create", 242 + "repo:*?action=create" 243 + )); 244 + assert!(!scope_covers( 245 + "repo:*?action=create", 246 + "repo:*?action=delete" 247 + )); 248 + assert!(scope_covers( 249 + "repo:*?action=create&action=delete", 250 + "repo:*?action=create" 251 + )); 252 + assert!(!scope_covers( 253 + "repo:*?action=create", 254 + "repo:*?action=create&action=delete" 255 + )); 256 + } 257 + 258 + #[test] 259 + fn test_scope_covers_no_granted_params_means_all() { 260 + assert!(scope_covers("repo:*", "repo:*?action=create")); 261 + assert!(scope_covers("repo:*", "repo:*?action=delete")); 262 + } 263 + 155 264 #[test] 156 265 fn test_validate_scopes_valid() { 157 - assert!(validate_delegation_scopes("atproto").is_ok()); 158 - assert!(validate_delegation_scopes("repo:* blob:*/*").is_ok()); 159 - assert!(validate_delegation_scopes("").is_ok()); 266 + assert!(ValidatedDelegationScope::new("atproto").is_ok()); 267 + assert!(ValidatedDelegationScope::new("repo:* blob:*/*").is_ok()); 268 + assert!(ValidatedDelegationScope::new("").is_ok()); 160 269 } 161 270 162 271 #[test] 163 272 fn test_validate_scopes_invalid() { 164 - assert!(validate_delegation_scopes("invalid:scope").is_err()); 273 + assert!(ValidatedDelegationScope::new("invalid:scope").is_err()); 165 274 } 166 275 167 276 #[test] 168 277 fn test_scope_presets_parse() { 169 278 SCOPE_PRESETS.iter().for_each(|p| { 170 - validate_delegation_scopes(p.scopes).unwrap_or_else(|e| { 279 + ValidatedDelegationScope::new(p.scopes).unwrap_or_else(|e| { 171 280 panic!( 172 281 "preset '{}' has invalid scopes '{}': {}", 173 282 p.name, p.scopes, e
+415
crates/tranquil-pds/src/oauth/client.rs
··· 1 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2 + use p256::ecdsa::SigningKey; 3 + use rand::rngs::OsRng; 4 + use reqwest::Client; 5 + use serde::{Deserialize, Serialize}; 6 + use std::sync::Arc; 7 + use std::time::Duration; 8 + use thiserror::Error; 9 + use tranquil_oauth::{ 10 + AuthorizationServerMetadata, ClientMetadata, compute_es256_jkt, compute_pkce_challenge, 11 + create_dpop_proof, 12 + }; 13 + use tranquil_types::Did; 14 + 15 + use crate::cache::Cache; 16 + 17 + #[derive(Error, Debug)] 18 + pub enum CrossPdsError { 19 + #[error("failed to fetch OAuth metadata: {0}")] 20 + MetadataFetch(String), 21 + #[error("controller PDS has no PAR endpoint")] 22 + NoParEndpoint, 23 + #[error("PAR request failed: {0}")] 24 + ParFailed(String), 25 + #[error("token exchange failed: {0}")] 26 + TokenExchangeFailed(String), 27 + #[error("invalid token response: {0}")] 28 + InvalidTokenResponse(String), 29 + } 30 + 31 + #[derive(Debug, Clone, Serialize, Deserialize)] 32 + pub struct CrossPdsAuthState { 33 + pub original_request_uri: String, 34 + pub controller_did: Did, 35 + pub controller_pds_url: String, 36 + pub code_verifier: String, 37 + pub dpop_private_key_der: String, 38 + pub delegated_did: Did, 39 + pub expected_issuer: Option<String>, 40 + } 41 + 42 + #[derive(Debug, Clone, Serialize, Deserialize)] 43 + pub struct ParResult { 44 + pub request_uri: String, 45 + pub authorize_url: String, 46 + } 47 + 48 + pub struct DelegationOAuthUrls { 49 + pub client_id: String, 50 + pub redirect_uri: String, 51 + } 52 + 53 + pub fn delegation_oauth_urls(hostname: &str) -> DelegationOAuthUrls { 54 + DelegationOAuthUrls { 55 + client_id: format!("https://{}/oauth/delegation/client-metadata", hostname), 56 + redirect_uri: format!("https://{}/oauth/delegation/callback", hostname), 57 + } 58 + } 59 + 60 + pub struct CrossPdsOAuthClient { 61 + http: Client, 62 + cache: Arc<dyn Cache>, 63 + } 64 + 65 + impl CrossPdsOAuthClient { 66 + pub fn new(cache: Arc<dyn Cache>) -> Self { 67 + let http = Client::builder() 68 + .timeout(Duration::from_secs(15)) 69 + .connect_timeout(Duration::from_secs(5)) 70 + .build() 71 + .unwrap_or_else(|_| Client::new()); 72 + Self { http, cache } 73 + } 74 + 75 + pub async fn store_auth_state( 76 + &self, 77 + state_key: &str, 78 + auth_state: &CrossPdsAuthState, 79 + ) -> Result<(), CrossPdsError> { 80 + let cache_key = format!("cross_pds_state:{}", state_key); 81 + let json_bytes = serde_json::to_vec(auth_state) 82 + .map_err(|e| CrossPdsError::ParFailed(format!("serialize auth state: {}", e)))?; 83 + let encrypted = crate::config::encrypt_key(&json_bytes) 84 + .map_err(|e| CrossPdsError::ParFailed(format!("encrypt auth state: {}", e)))?; 85 + self.cache 86 + .set_bytes(&cache_key, &encrypted, Duration::from_secs(600)) 87 + .await 88 + .map_err(|e| CrossPdsError::ParFailed(format!("cache auth state: {}", e))) 89 + } 90 + 91 + pub async fn retrieve_auth_state( 92 + &self, 93 + state_key: &str, 94 + ) -> Result<CrossPdsAuthState, CrossPdsError> { 95 + let cache_key = format!("cross_pds_state:{}", state_key); 96 + let encrypted_bytes = self 97 + .cache 98 + .get_bytes(&cache_key) 99 + .await 100 + .ok_or_else(|| CrossPdsError::TokenExchangeFailed("auth state expired or not found".into()))?; 101 + let _ = self.cache.delete(&cache_key).await; 102 + let decrypted = crate::config::decrypt_key( 103 + &encrypted_bytes, 104 + Some(crate::config::ENCRYPTION_VERSION), 105 + ) 106 + .map_err(|e| CrossPdsError::TokenExchangeFailed(format!("decrypt auth state: {}", e)))?; 107 + serde_json::from_slice(&decrypted) 108 + .map_err(|e| CrossPdsError::TokenExchangeFailed(format!("deserialize auth state: {}", e))) 109 + } 110 + 111 + pub async fn check_remote_is_delegated(&self, pds_url: &str, did: &str) -> Option<bool> { 112 + let url = format!( 113 + "{}/oauth/security-status?identifier={}", 114 + pds_url.trim_end_matches('/'), 115 + urlencoding::encode(did) 116 + ); 117 + let resp = self.http.get(&url).send().await.ok()?; 118 + if !resp.status().is_success() { 119 + return None; 120 + } 121 + #[derive(Deserialize)] 122 + #[serde(rename_all = "camelCase")] 123 + struct RemoteSecurityStatus { 124 + is_delegated: Option<bool>, 125 + } 126 + resp.json::<RemoteSecurityStatus>() 127 + .await 128 + .ok() 129 + .and_then(|s| s.is_delegated) 130 + } 131 + 132 + async fn send_with_dpop_retry( 133 + &self, 134 + signing_key: &SigningKey, 135 + method: &str, 136 + url: &str, 137 + params: &[(&str, String)], 138 + access_token_hash: Option<&str>, 139 + ) -> Result<reqwest::Response, String> { 140 + let make_proof = |nonce: Option<&str>| { 141 + create_dpop_proof(signing_key, method, url, nonce, access_token_hash) 142 + .map_err(|e| format!("{:?}", e)) 143 + }; 144 + 145 + let resp = self.http.post(url).header("DPoP", &make_proof(None)?).form(params) 146 + .send().await.map_err(|e| e.to_string())?; 147 + 148 + let nonce = resp.headers().get("dpop-nonce") 149 + .and_then(|v| v.to_str().ok()).map(|s| s.to_string()); 150 + let needs_retry = matches!( 151 + resp.status(), 152 + reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNAUTHORIZED 153 + ); 154 + 155 + if needs_retry && nonce.is_some() { 156 + return self.http.post(url).header("DPoP", &make_proof(nonce.as_deref())?) 157 + .form(params).send().await.map_err(|e| e.to_string()); 158 + } 159 + Ok(resp) 160 + } 161 + 162 + fn require_https(url: &str, label: &str) -> Result<(), CrossPdsError> { 163 + if !url.starts_with("https://") { 164 + return Err(CrossPdsError::MetadataFetch(format!( 165 + "{} must use HTTPS, got: {}", 166 + label, url 167 + ))); 168 + } 169 + Ok(()) 170 + } 171 + 172 + async fn resolve_authorization_server(&self, pds_url: &str) -> Result<String, CrossPdsError> { 173 + Self::require_https(pds_url, "PDS URL")?; 174 + 175 + let resource_url = format!( 176 + "{}/.well-known/oauth-protected-resource", 177 + pds_url.trim_end_matches('/') 178 + ); 179 + if let Ok(resp) = self.http.get(&resource_url).send().await 180 + && resp.status().is_success() 181 + { 182 + #[derive(Deserialize)] 183 + struct ProtectedResource { 184 + authorization_servers: Option<Vec<String>>, 185 + } 186 + if let Ok(pr) = resp.json::<ProtectedResource>().await 187 + && let Some(server) = pr.authorization_servers.and_then(|s| s.into_iter().next()) 188 + { 189 + Self::require_https(&server, "Authorization server")?; 190 + return Ok(server); 191 + } 192 + } 193 + Ok(pds_url.trim_end_matches('/').to_string()) 194 + } 195 + 196 + pub async fn fetch_server_metadata( 197 + &self, 198 + pds_url: &str, 199 + ) -> Result<AuthorizationServerMetadata, CrossPdsError> { 200 + let cache_key = format!("cross_pds_oauth_meta:{}", pds_url); 201 + if let Some(cached) = self.cache.get(&cache_key).await 202 + && let Ok(meta) = serde_json::from_str(&cached) 203 + { 204 + return Ok(meta); 205 + } 206 + 207 + let auth_server = self.resolve_authorization_server(pds_url).await?; 208 + 209 + let url = format!("{}/.well-known/oauth-authorization-server", auth_server); 210 + let resp = self 211 + .http 212 + .get(&url) 213 + .send() 214 + .await 215 + .map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?; 216 + 217 + if !resp.status().is_success() { 218 + return Err(CrossPdsError::MetadataFetch(format!( 219 + "HTTP {} from {}", 220 + resp.status(), 221 + url 222 + ))); 223 + } 224 + 225 + let meta: AuthorizationServerMetadata = resp 226 + .json() 227 + .await 228 + .map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?; 229 + 230 + if let Ok(json_str) = serde_json::to_string(&meta) { 231 + let _ = self 232 + .cache 233 + .set(&cache_key, &json_str, Duration::from_secs(300)) 234 + .await; 235 + } 236 + 237 + Ok(meta) 238 + } 239 + 240 + pub async fn initiate_par( 241 + &self, 242 + pds_url: &str, 243 + urls: &DelegationOAuthUrls, 244 + login_hint: Option<&str>, 245 + original_request_uri: &str, 246 + controller_did: &Did, 247 + delegated_did: &Did, 248 + ) -> Result<(ParResult, CrossPdsAuthState, String), CrossPdsError> { 249 + let meta = self.fetch_server_metadata(pds_url).await?; 250 + let par_endpoint = meta 251 + .pushed_authorization_request_endpoint 252 + .as_deref() 253 + .ok_or(CrossPdsError::NoParEndpoint)?; 254 + 255 + let code_verifier = crate::util::generate_random_token(); 256 + let code_challenge = compute_pkce_challenge(&code_verifier); 257 + let state = crate::util::generate_random_token(); 258 + 259 + let signing_key = SigningKey::random(&mut OsRng); 260 + let dpop_key_der = URL_SAFE_NO_PAD.encode(signing_key.to_bytes()); 261 + 262 + let dpop_jkt = compute_es256_jkt(&signing_key) 263 + .map_err(|e| CrossPdsError::ParFailed(format!("{:?}", e)))?; 264 + 265 + let mut params = vec![ 266 + ("response_type", "code".to_string()), 267 + ("client_id", urls.client_id.clone()), 268 + ("redirect_uri", urls.redirect_uri.clone()), 269 + ("scope", "atproto".to_string()), 270 + ("state", state.clone()), 271 + ("code_challenge", code_challenge), 272 + ("code_challenge_method", "S256".to_string()), 273 + ("dpop_jkt", dpop_jkt), 274 + ]; 275 + if let Some(hint) = login_hint { 276 + params.push(("login_hint", hint.to_string())); 277 + } 278 + 279 + let resp = self 280 + .send_with_dpop_retry(&signing_key, "POST", par_endpoint, &params, None) 281 + .await 282 + .map_err(|e| CrossPdsError::ParFailed(e.to_string()))?; 283 + 284 + if !resp.status().is_success() { 285 + let body = resp.text().await.unwrap_or_default(); 286 + return Err(CrossPdsError::ParFailed(format!("PAR rejected: {}", body))); 287 + } 288 + 289 + #[derive(Deserialize)] 290 + struct ParResp { 291 + request_uri: String, 292 + } 293 + 294 + let par_resp: ParResp = resp 295 + .json() 296 + .await 297 + .map_err(|e| CrossPdsError::ParFailed(e.to_string()))?; 298 + 299 + let authorize_url = format!( 300 + "{}?request_uri={}&client_id={}", 301 + meta.authorization_endpoint, 302 + urlencoding::encode(&par_resp.request_uri), 303 + urlencoding::encode(&urls.client_id) 304 + ); 305 + 306 + let auth_state = CrossPdsAuthState { 307 + original_request_uri: original_request_uri.to_string(), 308 + controller_did: controller_did.clone(), 309 + controller_pds_url: pds_url.to_string(), 310 + code_verifier, 311 + dpop_private_key_der: dpop_key_der, 312 + delegated_did: delegated_did.clone(), 313 + expected_issuer: Some(meta.issuer.clone()), 314 + }; 315 + 316 + Ok(( 317 + ParResult { 318 + request_uri: par_resp.request_uri, 319 + authorize_url, 320 + }, 321 + auth_state, 322 + state, 323 + )) 324 + } 325 + 326 + pub async fn exchange_code( 327 + &self, 328 + auth_state: &CrossPdsAuthState, 329 + code: &str, 330 + client_id: &str, 331 + redirect_uri: &str, 332 + ) -> Result<String, CrossPdsError> { 333 + let meta = self 334 + .fetch_server_metadata(&auth_state.controller_pds_url) 335 + .await?; 336 + 337 + let key_bytes = URL_SAFE_NO_PAD 338 + .decode(&auth_state.dpop_private_key_der) 339 + .map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?; 340 + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()) 341 + .map_err(|e| CrossPdsError::TokenExchangeFailed(e.to_string()))?; 342 + 343 + let params = vec![ 344 + ("grant_type", "authorization_code".to_string()), 345 + ("code", code.to_string()), 346 + ("redirect_uri", redirect_uri.to_string()), 347 + ("code_verifier", auth_state.code_verifier.clone()), 348 + ("client_id", client_id.to_string()), 349 + ]; 350 + 351 + let resp = self 352 + .send_with_dpop_retry(&signing_key, "POST", &meta.token_endpoint, &params, None) 353 + .await 354 + .map_err(CrossPdsError::TokenExchangeFailed)?; 355 + 356 + if !resp.status().is_success() { 357 + let body = resp.text().await.unwrap_or_default(); 358 + return Err(CrossPdsError::TokenExchangeFailed(format!( 359 + "Token exchange rejected: {}", 360 + body 361 + ))); 362 + } 363 + 364 + #[derive(Deserialize)] 365 + struct TokenResp { 366 + sub: Option<String>, 367 + token_type: Option<String>, 368 + error: Option<String>, 369 + error_description: Option<String>, 370 + } 371 + 372 + let token_resp: TokenResp = resp 373 + .json() 374 + .await 375 + .map_err(|e| CrossPdsError::InvalidTokenResponse(e.to_string()))?; 376 + 377 + if let Some(ref err) = token_resp.error { 378 + let desc = token_resp.error_description.as_deref().unwrap_or("unknown"); 379 + return Err(CrossPdsError::TokenExchangeFailed(format!( 380 + "{}: {}", 381 + err, desc 382 + ))); 383 + } 384 + 385 + if let Some(ref tt) = token_resp.token_type 386 + && !tt.eq_ignore_ascii_case("DPoP") 387 + { 388 + return Err(CrossPdsError::InvalidTokenResponse(format!( 389 + "expected token_type DPoP, got {}", 390 + tt 391 + ))); 392 + } 393 + 394 + token_resp 395 + .sub 396 + .ok_or_else(|| CrossPdsError::InvalidTokenResponse("missing sub claim".to_string())) 397 + } 398 + } 399 + 400 + pub fn build_client_metadata(hostname: &str) -> ClientMetadata { 401 + let urls = delegation_oauth_urls(hostname); 402 + ClientMetadata { 403 + client_id: urls.client_id, 404 + client_name: Some(hostname.to_string()), 405 + client_uri: Some(format!("https://{}", hostname)), 406 + redirect_uris: vec![urls.redirect_uri], 407 + grant_types: vec!["authorization_code".to_string()], 408 + response_types: vec!["code".to_string()], 409 + scope: Some("atproto".to_string()), 410 + dpop_bound_access_tokens: Some(true), 411 + token_endpoint_auth_method: Some("none".to_string()), 412 + application_type: Some("web".to_string()), 413 + ..ClientMetadata::default() 414 + } 415 + }
+2 -1
crates/tranquil-pds/src/oauth/mod.rs
··· 1 + pub mod client; 1 2 pub mod db; 2 3 pub mod scopes; 3 4 pub mod verify; ··· 16 17 OAuthError, ParResponse, Prompt, ProtectedResourceMetadata, RefreshToken, RefreshTokenState, 17 18 RequestData, RequestId, ResponseMode, ResponseType, SessionId, TokenData, TokenId, 18 19 TokenRequest, TokenResponse, compute_access_token_hash, compute_jwk_thumbprint, 19 - verify_client_auth, 20 + compute_pkce_challenge, verify_client_auth, 20 21 }; 21 22 22 23 pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions};
+9
crates/tranquil-pds/src/state.rs
··· 3 3 use crate::cache::{Cache, DistributedRateLimiter, create_cache}; 4 4 use crate::circuit_breaker::CircuitBreakers; 5 5 use crate::config::AuthConfig; 6 + use crate::oauth::client::CrossPdsOAuthClient; 7 + use crate::plc::PlcClient; 6 8 use crate::rate_limit::RateLimiters; 7 9 use crate::repo::PostgresBlockStore; 8 10 use crate::repo_write_lock::RepoWriteLocks; ··· 57 59 pub sso_repo: Arc<dyn SsoRepository>, 58 60 pub sso_manager: SsoManager, 59 61 pub webauthn_config: Arc<WebAuthnConfig>, 62 + pub cross_pds_oauth: Arc<CrossPdsOAuthClient>, 60 63 pub shutdown: CancellationToken, 61 64 pub bootstrap_invite_code: Option<String>, 62 65 } ··· 204 207 } 205 208 206 209 impl AppState { 210 + pub fn plc_client(&self) -> PlcClient { 211 + PlcClient::with_cache(None, Some(self.cache.clone())) 212 + } 213 + 207 214 pub async fn new(shutdown: CancellationToken) -> Result<Self, Box<dyn Error>> { 208 215 let cfg = tranquil_config::get(); 209 216 let database_url = &cfg.database.url; ··· 272 279 let circuit_breakers = Arc::new(CircuitBreakers::new()); 273 280 let (cache, distributed_rate_limiter) = create_cache(shutdown.clone()).await; 274 281 let did_resolver = Arc::new(DidResolver::new()); 282 + let cross_pds_oauth = Arc::new(CrossPdsOAuthClient::new(cache.clone())); 275 283 let sso_config = SsoConfig::init(); 276 284 let sso_manager = SsoManager::from_config(sso_config); 277 285 let webauthn_config = Arc::new( ··· 302 310 cache, 303 311 distributed_rate_limiter, 304 312 did_resolver, 313 + cross_pds_oauth, 305 314 sso_manager, 306 315 webauthn_config, 307 316 shutdown,
+16 -3
crates/tranquil-pds/src/util.rs
··· 89 89 headers.get(name).and_then(|h| h.to_str().ok()) 90 90 } 91 91 92 + pub fn extract_user_agent(headers: &HeaderMap) -> Option<String> { 93 + headers 94 + .get("user-agent") 95 + .and_then(|v| v.to_str().ok()) 96 + .map(|s| s.to_string()) 97 + } 98 + 99 + pub fn generate_random_token() -> String { 100 + use base64::Engine as _; 101 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 102 + let bytes: [u8; 32] = rand::thread_rng().r#gen(); 103 + URL_SAFE_NO_PAD.encode(bytes) 104 + } 105 + 92 106 pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String { 93 107 if let Some(forwarded) = headers.get("x-forwarded-for") 94 108 && let Ok(value) = forwarded.to_str() ··· 183 197 } 184 198 if let Some(JsonValue::String(b64)) = obj.get("$bytes") 185 199 && obj.len() == 1 200 + && let Ok(bytes) = BASE64_STANDARD_INDIFFERENT.decode(b64) 186 201 { 187 - if let Ok(bytes) = BASE64_STANDARD_INDIFFERENT.decode(b64) { 188 - return Ipld::Bytes(bytes); 189 - } 202 + return Ipld::Bytes(bytes); 190 203 } 191 204 let map: BTreeMap<String, Ipld> = obj 192 205 .iter()
+4 -4
crates/tranquil-pds/tests/oauth_lifecycle.rs
··· 83 83 ("redirect_uri", redirect_uri), 84 84 ("code_challenge", &code_challenge), 85 85 ("code_challenge_method", "S256"), 86 - ("scope", "atproto"), 86 + ("scope", "atproto transition:generic"), 87 87 ]) 88 88 .send() 89 89 .await ··· 122 122 let consent_res = http_client 123 123 .post(format!("{}/oauth/authorize/consent", url)) 124 124 .header("Content-Type", "application/json") 125 - .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 125 + .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto", "transition:generic"], "remember": false})) 126 126 .send().await.expect("Consent request failed"); 127 127 assert_eq!( 128 128 consent_res.status(), ··· 631 631 let consent_res = http_client 632 632 .post(format!("{}/oauth/authorize/consent", url)) 633 633 .header("Content-Type", "application/json") 634 - .json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false})) 634 + .json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "transition:generic"], "remember": false})) 635 635 .send().await.unwrap(); 636 636 let consent_body: Value = consent_res.json().await.unwrap(); 637 637 location1 = consent_body["redirect_uri"].as_str().unwrap().to_string(); ··· 692 692 let consent_res = http_client 693 693 .post(format!("{}/oauth/authorize/consent", url)) 694 694 .header("Content-Type", "application/json") 695 - .json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false})) 695 + .json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "transition:generic"], "remember": false})) 696 696 .send().await.unwrap(); 697 697 let consent_body: Value = consent_res.json().await.unwrap(); 698 698 location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
+11 -69
crates/tranquil-pds/tests/oauth_scopes.rs
··· 131 131 let consent_res = http_client 132 132 .post(format!("{}/oauth/authorize/consent", url)) 133 133 .header("Content-Type", "application/json") 134 - .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 134 + .json(&json!({"request_uri": request_uri, "approved_scopes": scope.split_whitespace().collect::<Vec<_>>(), "remember": false})) 135 135 .send().await.expect("Consent request failed"); 136 136 assert_eq!( 137 137 consent_res.status(), ··· 178 178 } 179 179 180 180 #[tokio::test] 181 - async fn test_atproto_scope_allows_full_access() { 181 + async fn test_atproto_scope_denies_repo_writes() { 182 182 let url = base_url().await; 183 183 let http_client = client(); 184 184 let (session, _mock) = create_user_and_oauth_session_with_scope( ··· 197 197 "collection": collection, 198 198 "record": { 199 199 "$type": collection, 200 - "text": "Full access post", 200 + "text": "Should be denied", 201 201 "createdAt": Utc::now().to_rfc3339() 202 202 } 203 203 })) ··· 207 207 208 208 assert_eq!( 209 209 create_res.status(), 210 - StatusCode::OK, 211 - "atproto scope should allow creating records" 212 - ); 213 - let create_body: Value = create_res.json().await.unwrap(); 214 - let rkey = create_body["uri"] 215 - .as_str() 216 - .unwrap() 217 - .split('/') 218 - .next_back() 219 - .unwrap(); 220 - 221 - let put_res = http_client 222 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) 223 - .bearer_auth(&session.access_token) 224 - .json(&json!({ 225 - "repo": session.did, 226 - "collection": collection, 227 - "rkey": rkey, 228 - "record": { 229 - "$type": collection, 230 - "text": "Updated post", 231 - "createdAt": Utc::now().to_rfc3339() 232 - } 233 - })) 234 - .send() 235 - .await 236 - .unwrap(); 237 - assert_eq!( 238 - put_res.status(), 239 - StatusCode::OK, 240 - "atproto scope should allow updating records" 241 - ); 242 - 243 - let delete_res = http_client 244 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 245 - .bearer_auth(&session.access_token) 246 - .json(&json!({ 247 - "repo": session.did, 248 - "collection": collection, 249 - "rkey": rkey 250 - })) 251 - .send() 252 - .await 253 - .unwrap(); 254 - assert_eq!( 255 - delete_res.status(), 256 - StatusCode::OK, 257 - "atproto scope should allow deleting records" 210 + StatusCode::FORBIDDEN, 211 + "atproto scope alone should deny creating records" 258 212 ); 259 213 } 260 214 261 215 #[tokio::test] 262 - async fn test_atproto_scope_allows_blob_upload() { 216 + async fn test_atproto_scope_denies_blob_upload() { 263 217 let url = base_url().await; 264 218 let http_client = client(); 265 219 let (session, _mock) = create_user_and_oauth_session_with_scope( ··· 281 235 282 236 assert_eq!( 283 237 upload_res.status(), 284 - StatusCode::OK, 285 - "atproto scope should allow blob upload" 238 + StatusCode::FORBIDDEN, 239 + "atproto scope alone should deny blob upload" 286 240 ); 287 - let upload_body: Value = upload_res.json().await.unwrap(); 288 - assert!(upload_body["blob"]["ref"]["$link"].is_string()); 289 241 } 290 242 291 243 #[tokio::test] 292 - async fn test_atproto_scope_allows_batch_writes() { 244 + async fn test_atproto_scope_denies_batch_writes() { 293 245 let url = base_url().await; 294 246 let http_client = client(); 295 247 let (session, _mock) = create_user_and_oauth_session_with_scope( ··· 316 268 "text": "Batch post 1", 317 269 "createdAt": now 318 270 } 319 - }, 320 - { 321 - "$type": "com.atproto.repo.applyWrites#create", 322 - "collection": collection, 323 - "rkey": "batch-scope-2", 324 - "value": { 325 - "$type": collection, 326 - "text": "Batch post 2", 327 - "createdAt": now 328 - } 329 271 } 330 272 ] 331 273 })) ··· 335 277 336 278 assert_eq!( 337 279 apply_res.status(), 338 - StatusCode::OK, 339 - "atproto scope should allow batch writes" 280 + StatusCode::FORBIDDEN, 281 + "atproto scope alone should deny batch writes" 340 282 ); 341 283 } 342 284
+25 -23
crates/tranquil-pds/tests/scope_edge_cases.rs
··· 1 - use tranquil_pds::delegation::{intersect_scopes, scopes::validate_delegation_scopes}; 1 + use tranquil_pds::delegation::{ValidatedDelegationScope, intersect_scopes}; 2 2 use tranquil_pds::oauth::scopes::{ 3 3 AccountAction, IdentityAttr, ParsedScope, RepoAction, ScopePermissions, parse_scope, 4 4 parse_scope_string, ··· 140 140 #[test] 141 141 fn test_permissions_null_scope_defaults_atproto() { 142 142 let perms = ScopePermissions::from_scope_string(None); 143 - assert!(perms.has_full_access()); 144 - assert!(perms.allows_repo(RepoAction::Create, "any.collection")); 145 - assert!(perms.allows_repo(RepoAction::Update, "any.collection")); 146 - assert!(perms.allows_repo(RepoAction::Delete, "any.collection")); 143 + assert!(!perms.has_full_access()); 144 + assert!(!perms.allows_repo(RepoAction::Create, "any.collection")); 145 + assert!(!perms.allows_repo(RepoAction::Update, "any.collection")); 146 + assert!(!perms.allows_repo(RepoAction::Delete, "any.collection")); 147 147 } 148 148 149 149 #[test] ··· 177 177 } 178 178 179 179 #[test] 180 - fn test_delegation_intersect_params_behavior() { 180 + fn test_delegation_intersect_mismatched_params_empty() { 181 181 let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete"); 182 - 183 182 assert!( 184 - result.is_empty() || result.contains("repo:*"), 185 - "Delegation intersection with different action params: '{}'", 183 + result.is_empty(), 184 + "Mismatched action params must produce empty intersection, got: '{}'", 186 185 result 187 186 ); 188 187 } ··· 190 189 #[test] 191 190 fn test_delegation_intersect_wildcard_vs_specific() { 192 191 let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*"); 193 - assert!(result.contains("repo:")); 192 + assert_eq!( 193 + result, "repo:app.bsky.feed.post?action=create", 194 + "Intersection must return the narrower requested scope, not the granted wildcard" 195 + ); 194 196 } 195 197 196 198 #[test] 197 199 fn test_delegation_validate_known_prefixes() { 198 - assert!(validate_delegation_scopes("atproto").is_ok()); 199 - assert!(validate_delegation_scopes("repo:*").is_ok()); 200 - assert!(validate_delegation_scopes("blob:*/*").is_ok()); 201 - assert!(validate_delegation_scopes("rpc:*").is_ok()); 202 - assert!(validate_delegation_scopes("account:email").is_ok()); 203 - assert!(validate_delegation_scopes("identity:handle").is_ok()); 204 - assert!(validate_delegation_scopes("transition:generic").is_ok()); 200 + assert!(ValidatedDelegationScope::new("atproto").is_ok()); 201 + assert!(ValidatedDelegationScope::new("repo:*").is_ok()); 202 + assert!(ValidatedDelegationScope::new("blob:*/*").is_ok()); 203 + assert!(ValidatedDelegationScope::new("rpc:*").is_ok()); 204 + assert!(ValidatedDelegationScope::new("account:email").is_ok()); 205 + assert!(ValidatedDelegationScope::new("identity:handle").is_ok()); 206 + assert!(ValidatedDelegationScope::new("transition:generic").is_ok()); 205 207 } 206 208 207 209 #[test] 208 210 fn test_delegation_validate_unknown_prefixes() { 209 - assert!(validate_delegation_scopes("invalid:scope").is_err()); 210 - assert!(validate_delegation_scopes("custom:something").is_err()); 211 - assert!(validate_delegation_scopes("made:up").is_err()); 211 + assert!(ValidatedDelegationScope::new("invalid:scope").is_err()); 212 + assert!(ValidatedDelegationScope::new("custom:something").is_err()); 213 + assert!(ValidatedDelegationScope::new("made:up").is_err()); 212 214 } 213 215 214 216 #[test] 215 217 fn test_delegation_validate_empty() { 216 - assert!(validate_delegation_scopes("").is_ok()); 218 + assert!(ValidatedDelegationScope::new("").is_ok()); 217 219 } 218 220 219 221 #[test] 220 222 fn test_delegation_validate_multiple() { 221 - assert!(validate_delegation_scopes("atproto repo:* blob:*/*").is_ok()); 222 - assert!(validate_delegation_scopes("atproto invalid:scope").is_err()); 223 + assert!(ValidatedDelegationScope::new("atproto repo:* blob:*/*").is_ok()); 224 + assert!(ValidatedDelegationScope::new("atproto invalid:scope").is_err()); 223 225 } 224 226 225 227 #[test]
+5 -6
crates/tranquil-scopes/src/definitions.rs
··· 33 33 pub display_name: &'static str, 34 34 } 35 35 36 - pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> = LazyLock::new( 37 - || { 36 + pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> = 37 + LazyLock::new(|| { 38 38 let definitions = vec![ 39 39 ScopeDefinition { 40 40 scope: "atproto", 41 41 category: ScopeCategory::Core, 42 42 required: true, 43 - description: "Full access to read, write, and manage this account (when no granular permissions are specified)", 44 - display_name: "Full Account Access", 43 + description: "Identity verification and session establishment", 44 + display_name: "AT Protocol Access", 45 45 }, 46 46 ScopeDefinition { 47 47 scope: "transition:generic", ··· 109 109 ]; 110 110 111 111 definitions.into_iter().map(|d| (d.scope, d)).collect() 112 - }, 113 - ); 112 + }); 114 113 115 114 #[allow(dead_code)] 116 115 pub fn get_scope_definition(scope: &str) -> Option<&'static ScopeDefinition> {
+25 -32
crates/tranquil-scopes/src/permissions.rs
··· 24 24 25 25 let parsed = parse_scope_string(scope_str); 26 26 27 - let has_atproto = parsed.iter().any(|p| matches!(p, ParsedScope::Atproto)); 28 - let mut has_transition_generic = parsed 27 + let has_transition_generic = parsed 29 28 .iter() 30 29 .any(|p| matches!(p, ParsedScope::TransitionGeneric)); 31 30 let has_transition_chat = parsed ··· 35 34 .iter() 36 35 .any(|p| matches!(p, ParsedScope::TransitionEmail)); 37 36 38 - let has_granular_scopes = parsed.iter().any(|p| { 39 - matches!( 40 - p, 41 - ParsedScope::Repo(_) 42 - | ParsedScope::Blob(_) 43 - | ParsedScope::Rpc(_) 44 - | ParsedScope::Account(_) 45 - | ParsedScope::Identity(_) 46 - ) 47 - }); 48 - 49 - if has_atproto && !has_granular_scopes { 50 - has_transition_generic = true; 51 - } 52 - 53 37 Self { 54 38 scopes, 55 39 parsed, ··· 347 331 use super::*; 348 332 349 333 #[test] 350 - fn test_atproto_scope_allows_everything() { 334 + fn test_atproto_scope_is_identity_only() { 351 335 let perms = ScopePermissions::from_scope_string(Some("atproto")); 352 - assert!(perms.has_full_access()); 353 - assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 354 - assert!(perms.allows_blob("image/png")); 355 - assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 356 - assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage)); 336 + assert!(!perms.has_full_access()); 337 + assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 338 + assert!(!perms.allows_blob("image/png")); 339 + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 340 + assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage)); 357 341 } 358 342 359 343 #[test] ··· 374 358 #[test] 375 359 fn test_empty_scope_defaults_to_atproto() { 376 360 let perms = ScopePermissions::from_scope_string(None); 377 - assert!(perms.has_full_access()); 361 + assert!(perms.has_scope("atproto")); 362 + assert!(!perms.has_full_access()); 363 + assert!(!perms.allows_repo(RepoAction::Create, "any.collection")); 378 364 } 379 365 380 366 #[test] ··· 491 477 } 492 478 493 479 #[test] 494 - fn test_identity_scope_with_atproto() { 480 + fn test_identity_scope_with_atproto_alone() { 495 481 let perms = ScopePermissions::from_scope_string(Some("atproto")); 482 + assert!(!perms.allows_identity(IdentityAttr::Handle)); 483 + assert!(!perms.allows_identity(IdentityAttr::Wildcard)); 484 + } 485 + 486 + #[test] 487 + fn test_transition_generic_grants_identity() { 488 + let perms = ScopePermissions::from_scope_string(Some("transition:generic")); 496 489 assert!(perms.allows_identity(IdentityAttr::Handle)); 497 490 assert!(perms.allows_identity(IdentityAttr::Wildcard)); 498 491 } ··· 517 510 } 518 511 519 512 #[test] 520 - fn test_atproto_alone_has_full_access() { 513 + fn test_atproto_alone_grants_nothing() { 521 514 let perms = ScopePermissions::from_scope_string(Some("atproto")); 522 - assert!(perms.has_full_access()); 523 - assert!(perms.allows_repo(RepoAction::Create, "any.collection")); 524 - assert!(perms.allows_repo(RepoAction::Delete, "any.collection")); 525 - assert!(perms.allows_repo(RepoAction::Update, "any.collection")); 526 - assert!(perms.allows_blob("image/png")); 527 - assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 515 + assert!(!perms.has_full_access()); 516 + assert!(!perms.allows_repo(RepoAction::Create, "any.collection")); 517 + assert!(!perms.allows_repo(RepoAction::Delete, "any.collection")); 518 + assert!(!perms.allows_repo(RepoAction::Update, "any.collection")); 519 + assert!(!perms.allows_blob("image/png")); 520 + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 528 521 } 529 522 530 523 #[test]
+35
crates/tranquil-types/src/lib.rs
··· 798 798 PasskeyRecovery, 799 799 MigrationVerification, 800 800 } 801 + 802 + pub mod did_doc { 803 + pub fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> { 804 + doc.get("service") 805 + .and_then(|s| s.as_array()) 806 + .and_then(|services| { 807 + services.iter().find_map(|svc| { 808 + let id = svc.get("id").and_then(|v| v.as_str()).unwrap_or_default(); 809 + let svc_type = svc.get("type").and_then(|v| v.as_str()).unwrap_or_default(); 810 + if (id == "#atproto_pds" || id.ends_with("#atproto_pds")) 811 + && svc_type == "AtprotoPersonalDataServer" 812 + { 813 + svc.get("serviceEndpoint") 814 + .and_then(|v| v.as_str()) 815 + .map(|s| s.to_string()) 816 + } else { 817 + None 818 + } 819 + }) 820 + }) 821 + } 822 + 823 + pub fn extract_handle(doc: &serde_json::Value) -> Option<String> { 824 + doc.get("alsoKnownAs") 825 + .and_then(|a| a.as_array()) 826 + .and_then(|aliases| { 827 + aliases.iter().find_map(|alias| { 828 + alias 829 + .as_str() 830 + .and_then(|s| s.strip_prefix("at://")) 831 + .map(|h| h.to_string()) 832 + }) 833 + }) 834 + } 835 + }
+227 -17
frontend/src/components/dashboard/ControllersContent.svelte
··· 17 17 18 18 interface Controller { 19 19 did: Did 20 - handle: Handle 20 + handle?: Handle 21 21 grantedScopes: ScopeSet 22 22 grantedAt: string 23 23 isActive: boolean 24 + isLocal: boolean 24 25 } 25 26 26 27 interface ControlledAccount { ··· 48 49 let canControlAccounts = $derived(!hasControllers) 49 50 50 51 let showAddController = $state(false) 51 - let addControllerDid = $state('') 52 + let addControllerIdentifier = $state('') 52 53 let addControllerScopes = $state('atproto') 53 54 let addingController = $state(false) 54 55 let addControllerConfirmed = $state(false) 56 + let resolvedController = $state<{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean } | null>(null) 57 + let resolving = $state(false) 58 + let resolveError = $state('') 59 + 60 + let typeaheadResults = $state<Array<{ did: string; handle: string; displayName?: string; avatar?: string }>>([]) 61 + let typeaheadTimeout: ReturnType<typeof setTimeout> | null = null 62 + let showTypeahead = $state(false) 63 + 64 + function onControllerInput(value: string) { 65 + addControllerIdentifier = value 66 + resolvedController = null 67 + resolveError = '' 68 + 69 + if (typeaheadTimeout) clearTimeout(typeaheadTimeout) 70 + 71 + const trimmed = value.trim().replace(/^@/, '') 72 + if (trimmed.startsWith('did:') || trimmed.length < 2) { 73 + typeaheadResults = [] 74 + showTypeahead = false 75 + return 76 + } 77 + 78 + typeaheadTimeout = setTimeout(async () => { 79 + const resp = await fetch( 80 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(trimmed)}&limit=5` 81 + ) 82 + if (resp.ok) { 83 + const data = await resp.json() 84 + typeaheadResults = (data.actors ?? []).map((a: Record<string, unknown>) => ({ 85 + did: a.did as string, 86 + handle: a.handle as string, 87 + displayName: a.displayName as string | undefined, 88 + avatar: a.avatar as string | undefined, 89 + })) 90 + showTypeahead = typeaheadResults.length > 0 91 + } 92 + }, 200) 93 + } 94 + 95 + function selectTypeahead(actor: { did: string; handle: string }) { 96 + addControllerIdentifier = actor.handle 97 + showTypeahead = false 98 + typeaheadResults = [] 99 + resolveControllerIdentifier() 100 + } 101 + 102 + async function resolveControllerIdentifier() { 103 + const identifier = addControllerIdentifier.trim().replace(/^@/, '') 104 + if (!identifier) return 105 + 106 + resolving = true 107 + resolveError = '' 108 + resolvedController = null 109 + 110 + const result = await api.resolveController(identifier) 111 + if (result.ok) { 112 + resolvedController = result.value 113 + } else { 114 + resolveError = $_('delegation.controllerNotFound') 115 + } 116 + resolving = false 117 + } 55 118 56 119 let showCreateDelegated = $state(false) 57 120 let newDelegatedHandle = $state('') ··· 77 140 handle: c.handle, 78 141 grantedScopes: c.grantedScopes, 79 142 grantedAt: c.grantedAt, 80 - isActive: c.isActive 143 + isActive: c.isActive, 144 + isLocal: c.isLocal 81 145 })) 82 146 } 83 147 } ··· 107 171 } 108 172 109 173 async function addController() { 110 - if (!addControllerDid.trim()) return 174 + if (!resolvedController) return 111 175 addingController = true 112 176 113 - const controllerDid = unsafeAsDid(addControllerDid.trim()) 177 + const controllerDid = unsafeAsDid(resolvedController.did) 114 178 const scopes = unsafeAsScopeSet(addControllerScopes) 115 179 const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes) 116 180 if (result.ok) { 117 181 toast.success($_('delegation.controllerAdded')) 118 - addControllerDid = '' 182 + addControllerIdentifier = '' 119 183 addControllerScopes = 'atproto' 120 184 addControllerConfirmed = false 185 + resolvedController = null 121 186 showAddController = false 122 187 await loadControllers() 123 188 } ··· 182 247 <div class="item-card" class:inactive={!controller.isActive}> 183 248 <div class="item-info"> 184 249 <div class="item-header"> 185 - <span class="item-handle">@{controller.handle || controller.did}</span> 250 + <span class="item-handle">{controller.handle ? `@${controller.handle}` : controller.did}</span> 186 251 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 187 252 {#if !controller.isActive} 188 253 <span class="badge inactive">{$_('delegation.inactive')}</span> ··· 227 292 </ul> 228 293 </div> 229 294 230 - <div class="field"> 231 - <label for="controllerDid">{$_('delegation.controllerDid')}</label> 232 - <input 233 - id="controllerDid" 234 - type="text" 235 - bind:value={addControllerDid} 236 - placeholder="did:plc:..." 237 - disabled={addingController} 238 - /> 295 + <div class="field controller-search"> 296 + <label for="controllerIdentifier">{$_('delegation.controllerIdentifier')}</label> 297 + <div class="search-wrapper"> 298 + <input 299 + id="controllerIdentifier" 300 + type="text" 301 + value={addControllerIdentifier} 302 + oninput={(e) => onControllerInput((e.target as HTMLInputElement).value)} 303 + onblur={() => { setTimeout(() => { showTypeahead = false }, 200) }} 304 + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); showTypeahead = false; resolveControllerIdentifier() } }} 305 + placeholder="handle or did:plc:..." 306 + disabled={addingController} 307 + /> 308 + {#if showTypeahead && typeaheadResults.length > 0} 309 + <div class="typeahead-dropdown"> 310 + {#each typeaheadResults as actor} 311 + <button type="button" class="typeahead-item" onmousedown={() => selectTypeahead(actor)}> 312 + {#if actor.avatar} 313 + <img src={actor.avatar} alt="" class="typeahead-avatar" /> 314 + {/if} 315 + <div class="typeahead-text"> 316 + {#if actor.displayName} 317 + <span class="typeahead-name">{actor.displayName}</span> 318 + {/if} 319 + <span class="typeahead-handle">@{actor.handle}</span> 320 + </div> 321 + </button> 322 + {/each} 323 + </div> 324 + {/if} 325 + </div> 326 + {#if resolving} 327 + <span class="resolve-status">{$_('common.loading')}</span> 328 + {:else if resolveError} 329 + <span class="resolve-status error">{resolveError}</span> 330 + {:else if resolvedController} 331 + <div class="resolved-info"> 332 + <span class="resolved-did">{resolvedController.did}</span> 333 + {#if resolvedController.handle} 334 + <span class="resolved-handle">@{resolvedController.handle}</span> 335 + {/if} 336 + {#if !resolvedController.isLocal && resolvedController.pdsUrl} 337 + <span class="badge external">{new URL(resolvedController.pdsUrl).hostname}</span> 338 + {/if} 339 + </div> 340 + {/if} 239 341 </div> 240 342 <div class="field"> 241 343 <label for="controllerScopes">{$_('delegation.accessLevel')}</label> ··· 253 355 <button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> 254 356 {$_('common.cancel')} 255 357 </button> 256 - <button type="button" onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}> 358 + <button type="button" onclick={addController} disabled={addingController || !resolvedController || !addControllerConfirmed}> 257 359 {addingController ? $_('delegation.adding') : $_('delegation.addController')} 258 360 </button> 259 361 </div> ··· 636 738 justify-content: flex-end; 637 739 } 638 740 741 + .controller-search { 742 + position: relative; 743 + } 744 + 745 + .search-wrapper { 746 + position: relative; 747 + } 748 + 749 + .typeahead-dropdown { 750 + position: absolute; 751 + top: 100%; 752 + left: 0; 753 + right: 0; 754 + z-index: 10; 755 + background: var(--bg-card); 756 + border: 1px solid var(--border-color); 757 + border-radius: var(--radius-md); 758 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 759 + max-height: 240px; 760 + overflow-y: auto; 761 + } 762 + 763 + .typeahead-item { 764 + display: flex; 765 + align-items: center; 766 + gap: var(--space-2); 767 + width: 100%; 768 + padding: var(--space-2) var(--space-3); 769 + border: none; 770 + background: transparent; 771 + cursor: pointer; 772 + text-align: left; 773 + color: var(--text-primary); 774 + } 775 + 776 + .typeahead-item:hover { 777 + background: var(--bg-tertiary); 778 + } 779 + 780 + .typeahead-avatar { 781 + width: 28px; 782 + height: 28px; 783 + border-radius: 50%; 784 + flex-shrink: 0; 785 + } 786 + 787 + .typeahead-text { 788 + display: flex; 789 + flex-direction: column; 790 + min-width: 0; 791 + } 792 + 793 + .typeahead-name { 794 + font-size: var(--text-sm); 795 + font-weight: var(--font-medium); 796 + white-space: nowrap; 797 + overflow: hidden; 798 + text-overflow: ellipsis; 799 + } 800 + 801 + .typeahead-handle { 802 + font-size: var(--text-xs); 803 + color: var(--text-secondary); 804 + white-space: nowrap; 805 + overflow: hidden; 806 + text-overflow: ellipsis; 807 + } 808 + 809 + .resolve-status { 810 + display: block; 811 + font-size: var(--text-xs); 812 + color: var(--text-secondary); 813 + margin-top: var(--space-1); 814 + } 815 + 816 + .resolve-status.error { 817 + color: var(--error-text); 818 + } 819 + 820 + .resolved-info { 821 + display: flex; 822 + align-items: center; 823 + gap: var(--space-2); 824 + flex-wrap: wrap; 825 + margin-top: var(--space-2); 826 + padding: var(--space-2) var(--space-3); 827 + background: var(--bg-tertiary); 828 + border-radius: var(--radius-md); 829 + font-size: var(--text-xs); 830 + } 831 + 832 + .resolved-did { 833 + font-family: var(--font-mono); 834 + color: var(--text-secondary); 835 + word-break: break-all; 836 + } 837 + 838 + .resolved-handle { 839 + color: var(--text-primary); 840 + font-weight: var(--font-medium); 841 + } 842 + 843 + .badge.external { 844 + background: var(--info-bg, var(--bg-tertiary)); 845 + color: var(--info-text, var(--text-secondary)); 846 + border: 1px solid var(--info-border, var(--border-color)); 847 + } 848 + 639 849 @media (max-width: 600px) { 640 850 .item-card { 641 851 flex-direction: column;
+15 -1
frontend/src/lib/api.ts
··· 326 326 const c = raw as Record<string, unknown>; 327 327 return { 328 328 did: unsafeAsDid(c.did as string), 329 - handle: unsafeAsHandle(c.handle as string), 329 + handle: c.handle ? unsafeAsHandle(c.handle as string) : undefined, 330 330 grantedScopes: unsafeAsScopeSet( 331 331 (c.granted_scopes ?? c.grantedScopes) as string, 332 332 ), ··· 334 334 (c.granted_at ?? c.grantedAt ?? c.added_at) as string, 335 335 ), 336 336 isActive: (c.is_active ?? c.isActive ?? true) as boolean, 337 + isLocal: (c.is_local ?? c.isLocal ?? true) as boolean, 337 338 }; 338 339 } 339 340 ··· 1471 1472 return xrpcResult("_delegation.getScopePresets"); 1472 1473 }, 1473 1474 1475 + resolveController( 1476 + identifier: string, 1477 + ): Promise< 1478 + Result< 1479 + { did: string; handle?: string; pdsUrl?: string; isLocal: boolean }, 1480 + ApiError 1481 + > 1482 + > { 1483 + return xrpcResult("_delegation.resolveController", { 1484 + params: { identifier }, 1485 + }); 1486 + }, 1487 + 1474 1488 addDelegationController( 1475 1489 token: AccessToken, 1476 1490 controllerDid: Did,
+2 -1
frontend/src/lib/types/api.ts
··· 570 570 571 571 export interface DelegationController { 572 572 did: Did; 573 - handle: Handle; 573 + handle?: Handle; 574 574 grantedScopes: ScopeSet; 575 575 grantedAt: ISODateString; 576 576 isActive: boolean; 577 + isLocal: boolean; 577 578 } 578 579 579 580 export interface DelegationControlledAccount {
+6 -1
frontend/src/locales/en.json
··· 569 569 "required": "Required", 570 570 "rememberChoiceLabel": "Remember my choice for this application", 571 571 "scopes": { 572 + "atproto": { 573 + "name": "AT Protocol Access", 574 + "description": "Identity verification and session establishment" 575 + }, 572 576 "atprotoWithGranular": { 573 577 "name": "AT Protocol Access", 574 - "description": "AT Protocol baseline scope (permissions determined by selected options below)" 578 + "description": "AT Protocol baseline (permissions determined by selected options below)" 575 579 } 576 580 }, 577 581 "unexpectedState": { ··· 818 822 "cannotAddControllers": "You cannot add controllers because this account controls other accounts. An account can either have controllers or control other accounts, but not both.", 819 823 "addController": "Add Controller", 820 824 "controllerDid": "Controller DID", 825 + "controllerIdentifier": "Controller handle or DID", 821 826 "accessLevel": "Access Level", 822 827 "adding": "Adding...", 823 828 "addControllerButton": "+ Add Controller",
+4
frontend/src/locales/fi.json
··· 575 575 "required": "Vaaditaan", 576 576 "rememberChoiceLabel": "Muista valintani tรคlle sovellukselle", 577 577 "scopes": { 578 + "atproto": { 579 + "name": "AT Protocol -kรคyttรถoikeus", 580 + "description": "Henkilรถllisyyden varmennus ja istunnon muodostus" 581 + }, 578 582 "atprotoWithGranular": { 579 583 "name": "AT Protocol -kรคyttรถoikeus", 580 584 "description": "AT Protocol -peruslaajuus (oikeudet mรครคrรคytyvรคt alla valittujen vaihtoehtojen mukaan)"
+4
frontend/src/locales/ja.json
··· 575 575 "required": "ๅฟ…้ ˆ", 576 576 "rememberChoiceLabel": "ใ“ใฎใ‚ขใƒ—ใƒชใซๅฏพใ™ใ‚‹้ธๆŠžใ‚’่จ˜ๆ†ถใ™ใ‚‹", 577 577 "scopes": { 578 + "atproto": { 579 + "name": "AT Protocol ใ‚ขใ‚ฏใ‚ปใ‚น", 580 + "description": "ๆœฌไบบ็ขบ่ชใจใ‚ปใƒƒใ‚ทใƒงใƒณ็ขบ็ซ‹" 581 + }, 578 582 "atprotoWithGranular": { 579 583 "name": "AT Protocol ใ‚ขใ‚ฏใ‚ปใ‚น", 580 584 "description": "AT Protocol ๅŸบๆœฌใ‚นใ‚ณใƒผใƒ—๏ผˆๆจฉ้™ใฏไปฅไธ‹ใง้ธๆŠžใ—ใŸใ‚ชใƒ—ใ‚ทใƒงใƒณใซใ‚ˆใฃใฆๆฑบใพใ‚Šใพใ™๏ผ‰"
+4
frontend/src/locales/ko.json
··· 575 575 "required": "ํ•„์ˆ˜", 576 576 "rememberChoiceLabel": "์ด ์•ฑ์— ๋Œ€ํ•œ ์„ ํƒ ๊ธฐ์–ตํ•˜๊ธฐ", 577 577 "scopes": { 578 + "atproto": { 579 + "name": "AT Protocol ์•ก์„ธ์Šค", 580 + "description": "์‹ ์› ํ™•์ธ ๋ฐ ์„ธ์…˜ ์„ค์ •" 581 + }, 578 582 "atprotoWithGranular": { 579 583 "name": "AT Protocol ์•ก์„ธ์Šค", 580 584 "description": "AT Protocol ๊ธฐ๋ณธ ๋ฒ”์œ„ (๊ถŒํ•œ์€ ์•„๋ž˜ ์„ ํƒํ•œ ์˜ต์…˜์— ์˜ํ•ด ๊ฒฐ์ •๋จ)"
+4
frontend/src/locales/sv.json
··· 575 575 "required": "Krรคvs", 576 576 "rememberChoiceLabel": "Kom ihรฅg mitt val fรถr denna applikation", 577 577 "scopes": { 578 + "atproto": { 579 + "name": "AT Protocol-รฅtkomst", 580 + "description": "Identitetsverifiering och sessionsupprรคttande" 581 + }, 578 582 "atprotoWithGranular": { 579 583 "name": "AT Protocol-รฅtkomst", 580 584 "description": "AT Protocol basomfattning (behรถrigheter bestรคms av valda alternativ nedan)"
+4
frontend/src/locales/zh.json
··· 575 575 "required": "ๅฟ…้œ€", 576 576 "rememberChoiceLabel": "่ฎฐไฝๅฏนๆญคๅบ”็”จ็š„ๆŽˆๆƒ้€‰ๆ‹ฉ", 577 577 "scopes": { 578 + "atproto": { 579 + "name": "AT Protocol ่ฎฟ้—ฎ", 580 + "description": "่บซไปฝ้ชŒ่ฏๅ’Œไผš่ฏๅปบ็ซ‹" 581 + }, 578 582 "atprotoWithGranular": { 579 583 "name": "AT Protocol ่ฎฟ้—ฎ", 580 584 "description": "AT Protocol ๅŸบ็ก€่Œƒๅ›ด๏ผˆๆƒ้™็”ฑไธ‹ๆ–น้€‰ๆ‹ฉ็š„้€‰้กนๅ†ณๅฎš๏ผ‰"
+13 -420
frontend/src/routes/OAuthDelegation.svelte
··· 1 1 <script lang="ts"> 2 - import { navigate, routes } from '../lib/router.svelte' 3 2 import { _ } from '../lib/i18n' 4 - import { 5 - prepareRequestOptions, 6 - serializeAssertionResponse, 7 - type WebAuthnRequestOptionsResponse, 8 - } from '../lib/webauthn' 9 3 10 4 let delegatedDid = $state<string | null>(null) 11 5 let delegatedHandle = $state<string | null>(null) 12 6 let controllerIdentifier = $state('') 13 - let controllerDid = $state<string | null>(null) 14 - let password = $state('') 15 - let rememberDevice = $state(false) 16 7 let submitting = $state(false) 17 8 let loading = $state(true) 18 9 let error = $state<string | null>(null) 19 - let hasPasskeys = $state(false) 20 - let hasTotp = $state(false) 21 - let passkeySupported = $state(false) 22 - let step = $state<'identifier' | 'password'>('identifier') 23 - 24 - $effect(() => { 25 - passkeySupported = window.PublicKeyCredential !== undefined 26 - }) 27 10 28 11 function getRequestUri(): string | null { 29 12 const params = new URLSearchParams(window.location.search) ··· 50 33 } 51 34 52 35 try { 53 - const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`) 36 + const response = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`) 54 37 if (response.ok) { 55 38 const data = await response.json() 56 39 delegatedHandle = data.handle || delegatedDid 57 40 } else { 58 - const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`) 59 - if (handleResponse.ok) { 60 - const data = await handleResponse.json() 61 - delegatedHandle = data.handle || delegatedDid 62 - } else { 63 - delegatedHandle = delegatedDid 64 - } 41 + delegatedHandle = delegatedDid 65 42 } 66 43 } catch { 67 44 delegatedHandle = delegatedDid ··· 70 47 } 71 48 } 72 49 73 - async function handleIdentifierSubmit(e: Event) { 50 + async function handleSubmit(e: Event) { 74 51 e.preventDefault() 75 52 if (!controllerIdentifier.trim()) return 76 53 ··· 91 68 resolvedDid = data.did 92 69 } 93 70 94 - controllerDid = resolvedDid 95 - 96 - const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`) 97 - if (securityResponse.ok) { 98 - const data = await securityResponse.json() 99 - hasPasskeys = passkeySupported && data.hasPasskeys === true 100 - hasTotp = data.hasTotp === true 101 - } 102 - 103 - step = 'password' 104 - } catch { 105 - error = $_('oauthDelegation.controllerNotFound') 106 - } finally { 107 - submitting = false 108 - } 109 - } 110 - 111 - async function handlePasskeyLogin() { 112 - const requestUri = getRequestUri() 113 - if (!requestUri || !controllerDid || !delegatedDid) { 114 - error = $_('oauthDelegation.missingInfo') 115 - return 116 - } 117 - 118 - submitting = true 119 - error = null 120 - 121 - try { 122 - const startResponse = await fetch('/oauth/passkey/start', { 123 - method: 'POST', 124 - headers: { 125 - 'Content-Type': 'application/json', 126 - 'Accept': 'application/json' 127 - }, 128 - body: JSON.stringify({ 129 - request_uri: requestUri, 130 - identifier: controllerIdentifier.trim().replace(/^@/, ''), 131 - delegated_did: delegatedDid 132 - }) 133 - }) 134 - 135 - if (!startResponse.ok) { 136 - const data = await startResponse.json() 137 - error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart') 138 - submitting = false 139 - return 140 - } 141 - 142 - const { options } = await startResponse.json() 143 - const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse) 144 - 145 - const credential = await navigator.credentials.get({ 146 - publicKey: publicKeyOptions 147 - }) as PublicKeyCredential | null 148 - 149 - if (!credential) { 150 - error = $_('oauthDelegation.passkeyCancelled') 151 - submitting = false 152 - return 153 - } 154 - 155 - const credentialData = serializeAssertionResponse(credential) 156 - 157 - const finishResponse = await fetch('/oauth/passkey/finish', { 158 - method: 'POST', 159 - headers: { 160 - 'Content-Type': 'application/json', 161 - 'Accept': 'application/json' 162 - }, 163 - body: JSON.stringify({ 164 - request_uri: requestUri, 165 - identifier: controllerIdentifier.trim().replace(/^@/, ''), 166 - credential: credentialData, 167 - delegated_did: delegatedDid, 168 - controller_did: controllerDid 169 - }) 170 - }) 171 - 172 - const data = await finishResponse.json() 173 - 174 - if (!finishResponse.ok || data.success === false || data.error) { 175 - error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed') 71 + const requestUri = getRequestUri() 72 + if (!requestUri || !delegatedDid) { 73 + error = $_('oauthDelegation.missingInfo') 176 74 submitting = false 177 75 return 178 76 } 179 77 180 - if (data.needs_totp) { 181 - navigate(routes.oauthTotp, { params: { request_uri: requestUri } }) 182 - return 183 - } 184 - 185 - if (data.needs_2fa) { 186 - navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } }) 187 - return 188 - } 189 - 190 - if (data.redirect_uri) { 191 - window.location.href = data.redirect_uri 192 - return 193 - } 194 - 195 - error = $_('oauthDelegation.unexpectedResponse') 196 - submitting = false 197 - } catch (e) { 198 - console.error('Passkey login error:', e) 199 - error = $_('oauthDelegation.authFailed') 200 - submitting = false 201 - } 202 - } 203 - 204 - async function handlePasswordSubmit(e: Event) { 205 - e.preventDefault() 206 - const requestUri = getRequestUri() 207 - if (!requestUri || !controllerDid || !delegatedDid) { 208 - error = $_('oauthDelegation.missingInfo') 209 - return 210 - } 211 - 212 - submitting = true 213 - error = null 214 - 215 - try { 216 78 const response = await fetch('/oauth/delegation/auth', { 217 79 method: 'POST', 218 80 headers: { ··· 222 84 body: JSON.stringify({ 223 85 request_uri: requestUri, 224 86 delegated_did: delegatedDid, 225 - controller_did: controllerDid, 226 - password, 227 - remember_device: rememberDevice 87 + controller_did: resolvedDid, 88 + auth_method: 'cross_pds' 228 89 }) 229 90 }) 230 91 231 92 const data = await response.json() 232 93 233 94 if (!response.ok || data.success === false || data.error) { 234 - error = data.error_description || data.error || $_('oauthDelegation.authFailed') 95 + error = data.error || $_('oauthDelegation.authFailed') 235 96 submitting = false 236 97 return 237 98 } 238 99 239 - if (data.needs_totp) { 240 - navigate(routes.oauthTotp, { params: { request_uri: requestUri } }) 241 - return 242 - } 243 - 244 - if (data.needs_2fa) { 245 - navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } }) 246 - return 247 - } 248 - 249 100 if (data.redirect_uri) { 250 101 window.location.href = data.redirect_uri 251 102 return ··· 254 105 error = $_('oauthDelegation.unexpectedResponse') 255 106 submitting = false 256 107 } catch { 257 - error = $_('oauthDelegation.authFailed') 108 + error = $_('oauthDelegation.controllerNotFound') 109 + } finally { 258 110 submitting = false 259 111 } 260 112 } ··· 285 137 window.history.back() 286 138 } 287 139 } 288 - 289 - function goBack() { 290 - step = 'identifier' 291 - password = '' 292 - error = null 293 - } 294 140 </script> 295 141 296 142 <div class="delegation-container"> ··· 298 144 <div class="loading"> 299 145 <p>{$_('oauthDelegation.loading')}</p> 300 146 </div> 301 - {:else if step === 'identifier'} 147 + {:else} 302 148 <header class="page-header"> 303 149 <h1>{$_('oauthDelegation.title')}</h1> 304 150 <p class="subtitle"> ··· 311 157 <div class="error">{error}</div> 312 158 {/if} 313 159 314 - <form onsubmit={handleIdentifierSubmit}> 160 + <form onsubmit={handleSubmit}> 315 161 <div class="field"> 316 162 <label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label> 317 163 <input ··· 334 180 </button> 335 181 </div> 336 182 </form> 337 - {:else if step === 'password'} 338 - <header class="page-header"> 339 - <h1>{$_('oauthDelegation.signInAsController')}</h1> 340 - <p class="subtitle"> 341 - {$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })} 342 - </p> 343 - </header> 344 - 345 - {#if error} 346 - <div class="error">{error}</div> 347 - {/if} 348 - 349 - <button class="back-link" onclick={goBack} disabled={submitting}> 350 - &larr; {$_('oauthDelegation.useDifferentController')} 351 - </button> 352 - 353 - <form onsubmit={handlePasswordSubmit}> 354 - {#if passkeySupported && hasPasskeys} 355 - <div class="auth-methods"> 356 - <div class="passkey-method"> 357 - <h3>{$_('oauthDelegation.signInWithPasskey')}</h3> 358 - <button 359 - type="button" 360 - class="passkey-btn" 361 - onclick={handlePasskeyLogin} 362 - disabled={submitting} 363 - > 364 - <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 365 - <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 366 - <path d="M17 17v4l3-2-3-2z" /> 367 - <path d="M12 11c-4 0-6 2-6 4v4h9" /> 368 - </svg> 369 - <span class="passkey-text"> 370 - {submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')} 371 - </span> 372 - </button> 373 - </div> 374 - 375 - <div class="method-divider"> 376 - <span>{$_('oauthDelegation.or')}</span> 377 - </div> 378 - 379 - <div class="password-method"> 380 - <h3>{$_('oauthDelegation.password')}</h3> 381 - <div class="field"> 382 - <input 383 - type="password" 384 - bind:value={password} 385 - disabled={submitting} 386 - required 387 - autocomplete="current-password" 388 - placeholder={$_('oauthDelegation.enterPassword')} 389 - /> 390 - </div> 391 - 392 - <label class="remember-device"> 393 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 394 - <span>{$_('oauthDelegation.rememberDevice')}</span> 395 - </label> 396 - 397 - <button type="submit" class="submit-btn" disabled={submitting || !password}> 398 - {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 399 - </button> 400 - </div> 401 - </div> 402 - {:else} 403 - <div class="field"> 404 - <label for="password">{$_('oauthDelegation.password')}</label> 405 - <input 406 - id="password" 407 - type="password" 408 - bind:value={password} 409 - disabled={submitting} 410 - required 411 - autocomplete="current-password" 412 - /> 413 - </div> 414 - 415 - <label class="remember-device"> 416 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 417 - <span>{$_('oauthDelegation.rememberDevice')}</span> 418 - </label> 419 - 420 - <div class="actions"> 421 - <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 422 - {$_('common.cancel')} 423 - </button> 424 - <button type="submit" class="submit-btn" disabled={submitting || !password}> 425 - {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 426 - </button> 427 - </div> 428 - {/if} 429 - </form> 430 - {:else} 431 - <header class="page-header"> 432 - <h1>{$_('oauthDelegation.title')}</h1> 433 - </header> 434 - <div class="error">{error || $_('oauthDelegation.unableToLoad')}</div> 435 - <div class="actions"> 436 - <button type="button" class="cancel-btn" onclick={handleCancel}> 437 - {$_('oauthDelegation.goBack')} 438 - </button> 439 - </div> 440 183 {/if} 441 184 </div> 442 185 ··· 469 212 line-height: 1.6; 470 213 } 471 214 472 - .back-link { 473 - display: inline-flex; 474 - align-items: center; 475 - padding: var(--space-2) 0; 476 - background: none; 477 - border: none; 478 - color: var(--accent); 479 - font-size: var(--text-sm); 480 - cursor: pointer; 481 - margin-bottom: var(--space-4); 482 - } 483 - 484 - .back-link:hover:not(:disabled) { 485 - text-decoration: underline; 486 - } 487 - 488 - .back-link:disabled { 489 - opacity: 0.6; 490 - cursor: not-allowed; 491 - } 492 - 493 215 form { 494 216 display: flex; 495 217 flex-direction: column; 496 218 gap: var(--space-4); 497 219 } 498 220 499 - .auth-methods { 500 - display: grid; 501 - grid-template-columns: 1fr; 502 - gap: var(--space-5); 503 - margin-top: var(--space-4); 504 - } 505 - 506 - @media (min-width: 600px) { 507 - .auth-methods { 508 - grid-template-columns: 1fr auto 1fr; 509 - align-items: start; 510 - } 511 - } 512 - 513 - .passkey-method, 514 - .password-method { 515 - display: flex; 516 - flex-direction: column; 517 - gap: var(--space-4); 518 - padding: var(--space-5); 519 - background: var(--bg-secondary); 520 - border-radius: var(--radius-xl); 521 - } 522 - 523 - .passkey-method h3, 524 - .password-method h3 { 525 - margin: 0; 526 - font-size: var(--text-sm); 527 - font-weight: var(--font-semibold); 528 - color: var(--text-secondary); 529 - text-transform: uppercase; 530 - letter-spacing: 0.05em; 531 - } 532 - 533 - .method-divider { 534 - display: flex; 535 - align-items: center; 536 - justify-content: center; 537 - color: var(--text-muted); 538 - font-size: var(--text-sm); 539 - } 540 - 541 - @media (min-width: 600px) { 542 - .method-divider { 543 - flex-direction: column; 544 - padding: 0 var(--space-3); 545 - } 546 - 547 - .method-divider::before, 548 - .method-divider::after { 549 - content: ''; 550 - width: 1px; 551 - height: var(--space-6); 552 - background: var(--border-color); 553 - } 554 - 555 - .method-divider span { 556 - writing-mode: vertical-rl; 557 - text-orientation: mixed; 558 - transform: rotate(180deg); 559 - padding: var(--space-2) 0; 560 - } 561 - } 562 - 563 - @media (max-width: 599px) { 564 - .method-divider { 565 - gap: var(--space-4); 566 - } 567 - 568 - .method-divider::before, 569 - .method-divider::after { 570 - content: ''; 571 - flex: 1; 572 - height: 1px; 573 - background: var(--border-color); 574 - } 575 - } 576 - 577 221 .field { 578 222 display: flex; 579 223 flex-direction: column; ··· 585 229 font-weight: var(--font-medium); 586 230 } 587 231 588 - input[type="password"], 589 232 input[type="text"] { 590 233 padding: var(--space-3); 591 234 border: 1px solid var(--border-color); ··· 600 243 border-color: var(--accent); 601 244 } 602 245 603 - .remember-device { 604 - display: flex; 605 - align-items: center; 606 - gap: var(--space-2); 607 - cursor: pointer; 608 - color: var(--text-secondary); 609 - font-size: var(--text-sm); 610 - } 611 - 612 - .remember-device input { 613 - width: 16px; 614 - height: 16px; 615 - } 616 - 617 246 .error { 618 247 padding: var(--space-3); 619 248 background: var(--error-bg); ··· 664 293 .submit-btn:hover:not(:disabled) { 665 294 background: var(--accent-hover); 666 295 } 667 - 668 - .passkey-btn { 669 - display: flex; 670 - align-items: center; 671 - justify-content: center; 672 - gap: var(--space-2); 673 - width: 100%; 674 - padding: var(--space-3); 675 - background: var(--accent); 676 - color: var(--text-inverse); 677 - border: 1px solid var(--accent); 678 - border-radius: var(--radius-md); 679 - font-size: var(--text-base); 680 - cursor: pointer; 681 - transition: background-color var(--transition-fast), border-color var(--transition-fast); 682 - } 683 - 684 - .passkey-btn:hover:not(:disabled) { 685 - background: var(--accent-hover); 686 - border-color: var(--accent-hover); 687 - } 688 - 689 - .passkey-btn:disabled { 690 - opacity: 0.6; 691 - cursor: not-allowed; 692 - } 693 - 694 - .passkey-icon { 695 - width: 20px; 696 - height: 20px; 697 - } 698 - 699 - .passkey-text { 700 - flex: 1; 701 - text-align: left; 702 - } 703 296 </style>
+3
migrations/20260316_cross_pds_delegation.sql
··· 1 + ALTER TABLE account_delegations DROP CONSTRAINT account_delegations_controller_did_fkey; 2 + ALTER TABLE account_delegations DROP CONSTRAINT account_delegations_granted_by_fkey; 3 + ALTER TABLE app_passwords DROP CONSTRAINT app_passwords_created_by_controller_did_fkey;

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
feat: cross-pds delegation
expand 0 comments
pull request successfully merged