+2072
-1696
Diff
round #0
+52
.sqlx/query-d8e646324c93b375cceccea533ddd880225931f29ce4d8c5184197fecce25fa7.json
+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
+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
+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
+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
+1
-1
Cargo.toml
+2
-1
Dockerfile
+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
+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
+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
+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
+1
crates/tranquil-api/src/identity/mod.rs
+2
-2
crates/tranquil-api/src/identity/plc/sign.rs
+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
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
+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
+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
+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
+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
+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
+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
+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(¶ms.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 ¶ms.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
+
¶ms.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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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, ¶ms, 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, ¶ms, 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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-
← {$_('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
+3
migrations/20260316_cross_pds_delegation.sql
History
1 round
0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat: cross-pds delegation
expand 0 comments
pull request successfully merged