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

fix: delegated acc passkey auth

+241 -13
+94 -11
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 311 311 312 312 if is_delegated { 313 313 tracing::info!("Redirecting to delegation auth"); 314 + if let Err(e) = state 315 + .oauth_repo 316 + .set_request_did(&request_id, &user.did) 317 + .await 318 + { 319 + tracing::error!(error = %e, "Failed to set delegated DID on authorization request"); 320 + return redirect_to_frontend_error( 321 + "server_error", 322 + "Failed to initialize delegation flow", 323 + ); 324 + } 314 325 return redirect_see_other(&format!( 315 326 "/app/oauth/delegation?request_uri={}&delegated_did={}", 316 327 url_encode(&request_uri), ··· 2137 2148 pub struct PasskeyStartInput { 2138 2149 pub request_uri: String, 2139 2150 pub identifier: String, 2151 + pub delegated_did: Option<String>, 2140 2152 } 2141 2153 2142 2154 #[derive(Debug, Serialize)] ··· 2394 2406 .into_response(); 2395 2407 } 2396 2408 2397 - if state 2409 + let delegation_from_param = match &form.delegated_did { 2410 + Some(delegated_did_str) => { 2411 + match delegated_did_str.parse::<tranquil_types::Did>() { 2412 + Ok(delegated_did) if delegated_did != user.did => { 2413 + match state 2414 + .delegation_repo 2415 + .get_delegation(&delegated_did, &user.did) 2416 + .await 2417 + { 2418 + Ok(Some(_)) => Some(delegated_did), 2419 + Ok(None) => None, 2420 + Err(e) => { 2421 + tracing::warn!( 2422 + error = %e, 2423 + delegated_did = %delegated_did, 2424 + controller_did = %user.did, 2425 + "Failed to verify delegation relationship" 2426 + ); 2427 + None 2428 + } 2429 + } 2430 + } 2431 + _ => None, 2432 + } 2433 + } 2434 + None => None, 2435 + }; 2436 + 2437 + let is_delegation_flow = delegation_from_param.is_some() 2438 + || request_data.did.as_ref().map_or(false, |existing_did| { 2439 + existing_did 2440 + .parse::<tranquil_types::Did>() 2441 + .ok() 2442 + .map_or(false, |parsed| parsed != user.did) 2443 + }); 2444 + 2445 + if let Some(delegated_did) = delegation_from_param { 2446 + tracing::info!( 2447 + delegated_did = %delegated_did, 2448 + controller_did = %user.did, 2449 + "Passkey auth with delegated_did param - setting delegation flow" 2450 + ); 2451 + if state 2452 + .oauth_repo 2453 + .set_authorization_did(&passkey_start_request_id, &delegated_did, None) 2454 + .await 2455 + .is_err() 2456 + { 2457 + return OAuthError::ServerError("An error occurred.".into()).into_response(); 2458 + } 2459 + if state 2460 + .oauth_repo 2461 + .set_controller_did(&passkey_start_request_id, &user.did) 2462 + .await 2463 + .is_err() 2464 + { 2465 + return OAuthError::ServerError("An error occurred.".into()).into_response(); 2466 + } 2467 + } else if is_delegation_flow { 2468 + tracing::info!( 2469 + delegated_did = ?request_data.did, 2470 + controller_did = %user.did, 2471 + "Passkey auth in delegation flow - preserving delegated DID" 2472 + ); 2473 + if state 2474 + .oauth_repo 2475 + .set_controller_did(&passkey_start_request_id, &user.did) 2476 + .await 2477 + .is_err() 2478 + { 2479 + return OAuthError::ServerError("An error occurred.".into()).into_response(); 2480 + } 2481 + } else if state 2398 2482 .oauth_repo 2399 2483 .set_authorization_did(&passkey_start_request_id, &user.did, None) 2400 2484 .await 2401 2485 .is_err() 2402 2486 { 2403 - return ( 2404 - StatusCode::INTERNAL_SERVER_ERROR, 2405 - Json(serde_json::json!({ 2406 - "error": "server_error", 2407 - "error_description": "An error occurred." 2408 - })), 2409 - ) 2410 - .into_response(); 2487 + return OAuthError::ServerError("An error occurred.".into()).into_response(); 2411 2488 } 2412 2489 2413 2490 let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); ··· 2497 2574 } 2498 2575 }; 2499 2576 2577 + let controller_did: Option<tranquil_types::Did> = request_data 2578 + .controller_did 2579 + .as_ref() 2580 + .and_then(|s| s.parse().ok()); 2581 + let passkey_owner_did = controller_did.as_ref().unwrap_or(&did); 2582 + 2500 2583 let auth_state_json = match state 2501 2584 .user_repo 2502 - .load_webauthn_challenge(&did, "authentication") 2585 + .load_webauthn_challenge(passkey_owner_did, "authentication") 2503 2586 .await 2504 2587 { 2505 2588 Ok(Some(s)) => s, ··· 2591 2674 2592 2675 if let Err(e) = state 2593 2676 .user_repo 2594 - .delete_webauthn_challenge(&did, "authentication") 2677 + .delete_webauthn_challenge(passkey_owner_did, "authentication") 2595 2678 .await 2596 2679 { 2597 2680 tracing::warn!(error = %e, "Failed to delete authentication state");
+7 -1
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
··· 127 127 .await 128 128 .is_err() 129 129 { 130 - tracing::warn!("Failed to set delegated DID on authorization request"); 130 + return Json(DelegationAuthResponse { 131 + success: false, 132 + needs_totp: None, 133 + redirect_uri: None, 134 + error: Some("Failed to update authorization request".to_string()), 135 + }) 136 + .into_response(); 131 137 } 132 138 133 139 let grant = match state
+138
crates/tranquil-pds/tests/oauth_security.rs
··· 1250 1250 "Error should be InsufficientScope" 1251 1251 ); 1252 1252 } 1253 + 1254 + #[tokio::test] 1255 + async fn test_delegation_oauth_token_sub_is_delegated_account() { 1256 + let url = base_url().await; 1257 + let http_client = client(); 1258 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 1259 + 1260 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 1261 + 1262 + let delegated_handle = format!("dlgsub{}", suffix); 1263 + let delegated_res = http_client 1264 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1265 + .bearer_auth(&controller_jwt) 1266 + .json(&json!({ 1267 + "handle": delegated_handle, 1268 + "controllerScopes": "atproto" 1269 + })) 1270 + .send() 1271 + .await 1272 + .unwrap(); 1273 + assert_eq!( 1274 + delegated_res.status(), 1275 + StatusCode::OK, 1276 + "Should create delegated account" 1277 + ); 1278 + let delegated_account: Value = delegated_res.json().await.unwrap(); 1279 + let delegated_did = delegated_account["did"].as_str().unwrap(); 1280 + 1281 + assert_ne!( 1282 + delegated_did, controller_did, 1283 + "Delegated DID should be different from controller DID" 1284 + ); 1285 + 1286 + let redirect_uri = "https://example.com/deleg-sub-callback"; 1287 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1288 + let client_id = mock_client.uri(); 1289 + let (code_verifier, code_challenge) = generate_pkce(); 1290 + 1291 + let par_body: Value = http_client 1292 + .post(format!("{}/oauth/par", url)) 1293 + .form(&[ 1294 + ("response_type", "code"), 1295 + ("client_id", &client_id), 1296 + ("redirect_uri", redirect_uri), 1297 + ("code_challenge", &code_challenge), 1298 + ("code_challenge_method", "S256"), 1299 + ("scope", "atproto"), 1300 + ("login_hint", delegated_did), 1301 + ]) 1302 + .send() 1303 + .await 1304 + .unwrap() 1305 + .json() 1306 + .await 1307 + .unwrap(); 1308 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1309 + 1310 + let auth_res = http_client 1311 + .post(format!("{}/oauth/delegation/auth", url)) 1312 + .header("Content-Type", "application/json") 1313 + .json(&json!({ 1314 + "request_uri": request_uri, 1315 + "delegated_did": delegated_did, 1316 + "controller_did": controller_did, 1317 + "password": "Testpass123!", 1318 + "remember_device": false 1319 + })) 1320 + .send() 1321 + .await 1322 + .unwrap(); 1323 + assert_eq!( 1324 + auth_res.status(), 1325 + StatusCode::OK, 1326 + "Delegation auth should succeed" 1327 + ); 1328 + let auth_body: Value = auth_res.json().await.unwrap(); 1329 + assert!( 1330 + auth_body["success"].as_bool().unwrap_or(false), 1331 + "Delegation auth should report success: {:?}", 1332 + auth_body 1333 + ); 1334 + 1335 + let consent_res = http_client 1336 + .post(format!("{}/oauth/authorize/consent", url)) 1337 + .header("Content-Type", "application/json") 1338 + .json(&json!({ 1339 + "request_uri": request_uri, 1340 + "approved_scopes": ["atproto"], 1341 + "remember": false 1342 + })) 1343 + .send() 1344 + .await 1345 + .unwrap(); 1346 + assert_eq!( 1347 + consent_res.status(), 1348 + StatusCode::OK, 1349 + "Consent should succeed" 1350 + ); 1351 + let consent_body: Value = consent_res.json().await.unwrap(); 1352 + let redirect_location = consent_body["redirect_uri"] 1353 + .as_str() 1354 + .expect("Expected redirect_uri"); 1355 + 1356 + let code = redirect_location 1357 + .split("code=") 1358 + .nth(1) 1359 + .unwrap() 1360 + .split('&') 1361 + .next() 1362 + .unwrap(); 1363 + 1364 + let token_res = http_client 1365 + .post(format!("{}/oauth/token", url)) 1366 + .form(&[ 1367 + ("grant_type", "authorization_code"), 1368 + ("code", code), 1369 + ("redirect_uri", redirect_uri), 1370 + ("code_verifier", &code_verifier), 1371 + ("client_id", &client_id), 1372 + ]) 1373 + .send() 1374 + .await 1375 + .unwrap(); 1376 + assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed"); 1377 + let tokens: Value = token_res.json().await.unwrap(); 1378 + 1379 + let sub = tokens["sub"].as_str().expect("Token response should have sub claim"); 1380 + 1381 + assert_eq!( 1382 + sub, delegated_did, 1383 + "Token sub claim should be the DELEGATED account's DID, not the controller's. Got {} but expected {}", 1384 + sub, delegated_did 1385 + ); 1386 + assert_ne!( 1387 + sub, controller_did, 1388 + "Token sub claim should NOT be the controller's DID" 1389 + ); 1390 + }
+2 -1
frontend/src/routes/OAuthDelegation.svelte
··· 127 127 }, 128 128 body: JSON.stringify({ 129 129 request_uri: requestUri, 130 - identifier: controllerIdentifier.trim().replace(/^@/, '') 130 + identifier: controllerIdentifier.trim().replace(/^@/, ''), 131 + delegated_did: delegatedDid 131 132 }) 132 133 }) 133 134