Delegated accounts

This commit is contained in:
lewis
2025-12-26 20:15:45 +02:00
parent 3f727b1c9d
commit ff59c29147
90 changed files with 6800 additions and 193 deletions
+976
View File
@@ -0,0 +1,976 @@
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::BearerAuth;
use crate::delegation::{self, DelegationActionType};
use crate::oauth::db as oauth_db;
use crate::state::{AppState, RateLimitKind};
use crate::util::extract_client_ip;
use crate::validation::is_valid_did;
use axum::{
Json,
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{error, info, warn};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerInfo {
pub did: String,
pub handle: String,
pub granted_scopes: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub is_active: bool,
}
#[derive(Debug, Serialize)]
pub struct ListControllersResponse {
pub controllers: Vec<ControllerInfo>,
}
pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -> Response {
let controllers = match delegation::get_delegations_for_account(&state.db, &auth.0.did).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to list controllers: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to list controllers"
})),
)
.into_response();
}
};
Json(ListControllersResponse {
controllers: controllers
.into_iter()
.map(|c| ControllerInfo {
did: c.did,
handle: c.handle,
granted_scopes: c.granted_scopes,
granted_at: c.granted_at,
is_active: c.is_active,
})
.collect(),
})
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct AddControllerInput {
pub controller_did: String,
pub granted_scopes: String,
}
pub async fn add_controller(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<AddControllerInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
}
let controller_exists: bool = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#,
input.controller_did
)
.fetch_one(&state.db)
.await
.unwrap_or(false);
if !controller_exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "ControllerNotFound",
"message": "Controller account not found"
})),
)
.into_response();
}
match delegation::controls_any_accounts(&state.db, &auth.0.did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidDelegation",
"message": "Cannot add controllers to an account that controls other accounts"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to check delegation status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to verify delegation status"
})),
)
.into_response();
}
Ok(false) => {}
}
match delegation::has_any_controllers(&state.db, &input.controller_did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidDelegation",
"message": "Cannot add a controlled account as a controller"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to verify controller status"
})),
)
.into_response();
}
Ok(false) => {}
}
match delegation::create_delegation(
&state.db,
&auth.0.did,
&input.controller_did,
&input.granted_scopes,
&auth.0.did,
)
.await
{
Ok(_) => {
let _ = delegation::log_delegation_action(
&state.db,
&auth.0.did,
&auth.0.did,
Some(&input.controller_did),
DelegationActionType::GrantCreated,
Some(serde_json::json!({
"granted_scopes": input.granted_scopes
})),
None,
None,
)
.await;
(
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response()
}
Err(e) => {
tracing::error!("Failed to add controller: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to add controller"
})),
)
.into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveControllerInput {
pub controller_did: String,
}
pub async fn remove_controller(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<RemoveControllerInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did)
.await
{
Ok(true) => {
let revoked_app_passwords = sqlx::query_scalar!(
r#"DELETE FROM app_passwords
WHERE user_id = (SELECT id FROM users WHERE did = $1)
AND created_by_controller_did = $2
RETURNING id"#,
auth.0.did,
input.controller_did
)
.fetch_all(&state.db)
.await
.map(|r| r.len())
.unwrap_or(0);
let revoked_oauth_tokens = oauth_db::revoke_tokens_for_controller(
&state.db,
&auth.0.did,
&input.controller_did,
)
.await
.unwrap_or(0);
let _ = delegation::log_delegation_action(
&state.db,
&auth.0.did,
&auth.0.did,
Some(&input.controller_did),
DelegationActionType::GrantRevoked,
Some(serde_json::json!({
"revoked_app_passwords": revoked_app_passwords,
"revoked_oauth_tokens": revoked_oauth_tokens
})),
None,
None,
)
.await;
(
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "DelegationNotFound",
"message": "No active delegation found for this controller"
})),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to remove controller: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to remove controller"
})),
)
.into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateControllerScopesInput {
pub controller_did: String,
pub granted_scopes: String,
}
pub async fn update_controller_scopes(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<UpdateControllerScopesInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
}
match delegation::update_delegation_scopes(
&state.db,
&auth.0.did,
&input.controller_did,
&input.granted_scopes,
)
.await
{
Ok(true) => {
let _ = delegation::log_delegation_action(
&state.db,
&auth.0.did,
&auth.0.did,
Some(&input.controller_did),
DelegationActionType::ScopesModified,
Some(serde_json::json!({
"new_scopes": input.granted_scopes
})),
None,
None,
)
.await;
(
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "DelegationNotFound",
"message": "No active delegation found for this controller"
})),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update controller scopes: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to update controller scopes"
})),
)
.into_response()
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DelegatedAccountInfo {
pub did: String,
pub handle: String,
pub granted_scopes: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct ListControlledAccountsResponse {
pub accounts: Vec<DelegatedAccountInfo>,
}
pub async fn list_controlled_accounts(State(state): State<AppState>, auth: BearerAuth) -> Response {
let accounts = match delegation::get_accounts_controlled_by(&state.db, &auth.0.did).await {
Ok(a) => a,
Err(e) => {
tracing::error!("Failed to list controlled accounts: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to list controlled accounts"
})),
)
.into_response();
}
};
Json(ListControlledAccountsResponse {
accounts: accounts
.into_iter()
.map(|a| DelegatedAccountInfo {
did: a.did,
handle: a.handle,
granted_scopes: a.granted_scopes,
granted_at: a.granted_at,
})
.collect(),
})
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct AuditLogParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
50
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuditLogEntry {
pub id: String,
pub delegated_did: String,
pub actor_did: String,
pub controller_did: Option<String>,
pub action_type: String,
pub action_details: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct GetAuditLogResponse {
pub entries: Vec<AuditLogEntry>,
pub total: i64,
}
pub async fn get_audit_log(
State(state): State<AppState>,
auth: BearerAuth,
Query(params): Query<AuditLogParams>,
) -> Response {
let limit = params.limit.min(100).max(1);
let offset = params.offset.max(0);
let entries =
match delegation::audit::get_audit_log_for_account(&state.db, &auth.0.did, limit, offset)
.await
{
Ok(e) => e,
Err(e) => {
tracing::error!("Failed to get audit log: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to get audit log"
})),
)
.into_response();
}
};
let total = match delegation::audit::count_audit_log_entries(&state.db, &auth.0.did).await {
Ok(t) => t,
Err(_) => 0,
};
Json(GetAuditLogResponse {
entries: entries
.into_iter()
.map(|e| AuditLogEntry {
id: e.id.to_string(),
delegated_did: e.delegated_did,
actor_did: e.actor_did,
controller_did: e.controller_did,
action_type: format!("{:?}", e.action_type),
action_details: e.action_details,
created_at: e.created_at,
})
.collect(),
total,
})
.into_response()
}
#[derive(Debug, Serialize)]
pub struct ScopePresetInfo {
pub name: &'static str,
pub label: &'static str,
pub description: &'static str,
pub scopes: &'static str,
}
#[derive(Debug, Serialize)]
pub struct GetScopePresetsResponse {
pub presets: Vec<ScopePresetInfo>,
}
pub async fn get_scope_presets() -> Response {
Json(GetScopePresetsResponse {
presets: delegation::SCOPE_PRESETS
.iter()
.map(|p| ScopePresetInfo {
name: p.name,
label: p.label,
description: p.description,
scopes: p.scopes,
})
.collect(),
})
.into_response()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDelegatedAccountInput {
pub handle: String,
pub email: Option<String>,
pub controller_scopes: String,
pub invite_code: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDelegatedAccountResponse {
pub did: String,
pub handle: String,
}
pub async fn create_delegated_account(
State(state): State<AppState>,
headers: HeaderMap,
auth: BearerAuth,
Json(input): Json<CreateDelegatedAccountInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
.await
{
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many account creation attempts. Please try again later."
})),
)
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
}
match delegation::has_any_controllers(&state.db, &auth.0.did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidDelegation",
"message": "Cannot create delegated accounts from a controlled account"
})),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "ServerError",
"message": "Failed to verify controller status"
})),
)
.into_response();
}
Ok(false) => {}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_suffix = format!(".{}", hostname);
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
input
.handle
.strip_suffix(&pds_suffix)
.unwrap_or(&input.handle)
} else {
&input.handle
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, hostname),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
)
.into_response();
}
}
} else {
input.handle.to_lowercase()
};
let email = input
.email
.as_ref()
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty());
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
}
if let Some(ref code) = input.invite_code {
let valid = sqlx::query_scalar!(
"SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
code
)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.unwrap_or(Some(false));
if valid != Some(true) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
)
.into_response();
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if invite_required {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
)
.into_response();
}
}
use k256::ecdsa::SigningKey;
use rand::rngs::OsRng;
let pds_endpoint = format!("https://{}", hostname);
let secret_key = k256::SecretKey::random(&mut OsRng);
let secret_key_bytes = secret_key.to_bytes().to_vec();
let signing_key = match SigningKey::from_slice(&secret_key_bytes) {
Ok(k) => k,
Err(e) => {
error!("Error creating signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match crate::plc::create_genesis_operation(
&signing_key,
&rotation_key,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": "Failed to create PLC operation"}),
),
)
.into_response();
}
};
let plc_client = crate::plc::PlcClient::new(None);
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
}
let did = genesis_result.did;
info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account");
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Error starting transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
account_type, preferred_comms_channel
) VALUES ($1, $2, $3, NULL, FALSE, 'delegated'::account_type, 'email'::comms_channel) RETURNING id"#,
)
.bind(&handle)
.bind(&email)
.bind(&did)
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("23505")
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
)
.into_response();
} else if constraint.contains("email") {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidEmail", "message": "Email already registered"}),
),
)
.into_response();
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(bytes) => bytes,
Err(e) => {
error!("Error encrypting signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
user_id,
&encrypted_key_bytes[..],
crate::config::ENCRYPTION_VERSION
)
.execute(&mut *tx)
.await
{
error!("Error inserting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query!(
r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
VALUES ($1, $2, $3, $4)"#,
did,
auth.0.did,
input.controller_scopes,
auth.0.did
)
.execute(&mut *tx)
.await
{
error!("Error creating initial delegation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let mst = Mst::new(Arc::new(state.block_store.clone()));
let mst_root = match mst.persist().await {
Ok(c) => c,
Err(e) => {
error!("Error persisting MST: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let rev = Tid::now(LimitedU32::MIN);
let (commit_bytes, _sig) =
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) {
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
error!("Error saving genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let commit_cid_str = commit_cid.to_string();
if let Err(e) = sqlx::query!(
"INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)",
user_id,
commit_cid_str
)
.execute(&mut *tx)
.await
{
error!("Error inserting repo: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Some(ref code) = input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
}
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle
});
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
"app.bsky.actor.profile",
"self",
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
let _ = delegation::log_delegation_action(
&state.db,
&did,
&auth.0.did,
Some(&auth.0.did),
DelegationActionType::GrantCreated,
Some(json!({
"account_created": true,
"granted_scopes": input.controller_scopes
})),
None,
None,
)
.await;
info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created");
Json(CreateDelegatedAccountResponse { did, handle }).into_response()
}
+5 -1
View File
@@ -42,6 +42,7 @@ pub enum ApiError {
AppPasswordNotFound,
InvalidSwap,
Forbidden,
InsufficientScope,
InvitesDisabled,
DatabaseError,
UpstreamFailure,
@@ -72,7 +73,9 @@ impl ApiError {
| Self::TokenRequired
| Self::AccountDeactivated
| Self::AccountTakedown => StatusCode::UNAUTHORIZED,
Self::Forbidden | Self::InvitesDisabled => StatusCode::FORBIDDEN,
Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => {
StatusCode::FORBIDDEN
}
Self::AccountNotFound
| Self::RepoNotFound
| Self::RepoNotFoundMsg(_)
@@ -114,6 +117,7 @@ impl ApiError {
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
Self::Forbidden => Cow::Borrowed("Forbidden"),
Self::InsufficientScope => Cow::Borrowed("InsufficientScope"),
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
+1
View File
@@ -1,5 +1,6 @@
pub mod actor;
pub mod admin;
pub mod delegation;
pub mod error;
pub mod identity;
pub mod moderation;
+24 -3
View File
@@ -1,4 +1,5 @@
use crate::auth::{ServiceTokenVerifier, is_service_token};
use crate::delegation::{self, DelegationActionType};
use crate::state::AppState;
use axum::body::Bytes;
use axum::{
@@ -39,7 +40,7 @@ pub async fn upload_blob(
let is_service_auth = is_service_token(&token);
let (did, is_migration) = if is_service_auth {
let (did, is_migration, controller_did) = if is_service_auth {
debug!("Verifying service token for blob upload");
let verifier = ServiceTokenVerifier::new();
match verifier
@@ -48,7 +49,7 @@ pub async fn upload_blob(
{
Ok(claims) => {
debug!("Service token verified for DID: {}", claims.iss);
(claims.iss, false)
(claims.iss, false, None)
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
@@ -82,7 +83,8 @@ pub async fn upload_blob(
.ok()
.flatten()
.flatten();
(user.did, deactivated.is_some())
let ctrl_did = user.controller_did.clone();
(user.did, deactivated.is_some(), ctrl_did)
}
Err(_) => {
return (
@@ -204,6 +206,25 @@ pub async fn upload_blob(
)
.into_response();
}
if let Some(ref controller) = controller_did {
let _ = delegation::log_delegation_action(
&state.db,
&did,
controller,
Some(controller),
DelegationActionType::BlobUpload,
Some(json!({
"cid": cid_str,
"mime_type": mime_type,
"size": size
})),
None,
None,
)
.await;
}
Json(json!({
"blob": {
"$type": "blob",
+62 -20
View File
@@ -1,6 +1,7 @@
use super::validation::validate_record;
use super::write::has_verified_comms_channel;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
@@ -109,6 +110,7 @@ pub async fn apply_writes(
let did = auth_user.did.clone();
let is_oauth = auth_user.is_oauth;
let scope = auth_user.scope;
let controller_did = auth_user.controller_did.clone();
if input.repo != did {
return (
StatusCode::FORBIDDEN,
@@ -116,26 +118,21 @@ pub async fn apply_writes(
)
.into_response();
}
match has_verified_comms_channel(&state.db, &did).await {
Ok(true) => {}
Ok(false) => {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response();
}
Err(e) => {
error!("DB error checking notification channels: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let is_verified = has_verified_comms_channel(&state.db, &did)
.await
.unwrap_or(false);
let is_delegated = crate::delegation::is_delegated_account(&state.db, &did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response();
}
if input.writes.is_empty() {
return (
@@ -485,6 +482,51 @@ pub async fn apply_writes(
.into_response();
}
};
if let Some(ref controller) = controller_did {
let write_summary: Vec<serde_json::Value> = input
.writes
.iter()
.map(|w| match w {
WriteOp::Create {
collection, rkey, ..
} => json!({
"action": "create",
"collection": collection,
"rkey": rkey
}),
WriteOp::Update {
collection, rkey, ..
} => json!({
"action": "update",
"collection": collection,
"rkey": rkey
}),
WriteOp::Delete { collection, rkey } => json!({
"action": "delete",
"collection": collection,
"rkey": rkey
}),
})
.collect();
let _ = delegation::log_delegation_action(
&state.db,
&did,
controller,
Some(controller),
DelegationActionType::RepoWrite,
Some(json!({
"action": "apply_writes",
"count": input.writes.len(),
"writes": write_summary
})),
None,
None,
)
.await;
}
(
StatusCode::OK,
Json(ApplyWritesOutput {
+23
View File
@@ -1,5 +1,6 @@
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::api::repo::record::write::prepare_repo_write;
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
@@ -52,6 +53,7 @@ pub async fn delete_record(
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
let controller_did = auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
@@ -124,6 +126,8 @@ pub async fn delete_record(
.into_response();
}
};
let collection_for_audit = input.collection.clone();
let rkey_for_audit = input.rkey.clone();
let op = RecordOp::Delete {
collection: input.collection,
rkey: input.rkey,
@@ -174,5 +178,24 @@ pub async fn delete_record(
)
.into_response();
};
if let Some(ref controller) = controller_did {
let _ = delegation::log_delegation_action(
&state.db,
&did,
controller,
Some(controller),
DelegationActionType::RepoWrite,
Some(json!({
"action": "delete",
"collection": collection_for_audit,
"rkey": rkey_for_audit
})),
None,
None,
)
.await;
}
(StatusCode::OK, Json(json!({}))).into_response()
}
+59 -20
View File
@@ -1,5 +1,6 @@
use super::validation::validate_record;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
@@ -55,6 +56,7 @@ pub struct RepoWriteAuth {
pub current_root_cid: Cid,
pub is_oauth: bool,
pub scope: Option<String>,
pub controller_did: Option<String>,
}
pub async fn prepare_repo_write(
@@ -99,26 +101,21 @@ pub async fn prepare_repo_write(
)
.into_response());
}
match has_verified_comms_channel(&state.db, &auth_user.did).await {
Ok(true) => {}
Ok(false) => {
return Err((
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response());
}
Err(e) => {
error!("DB error checking notification channels: {}", e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response());
}
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
.await
.unwrap_or(false);
let is_delegated = crate::delegation::is_delegated_account(&state.db, &auth_user.did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return Err((
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response());
}
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
@@ -172,6 +169,7 @@ pub async fn prepare_repo_write(
current_root_cid,
is_oauth: auth_user.is_oauth,
scope: auth_user.scope,
controller_did: auth_user.controller_did,
})
}
#[derive(Deserialize)]
@@ -215,6 +213,7 @@ pub async fn create_record(
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
let controller_did = auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
@@ -355,6 +354,25 @@ pub async fn create_record(
)
.into_response();
};
if let Some(ref controller) = controller_did {
let _ = delegation::log_delegation_action(
&state.db,
&did,
controller,
Some(controller),
DelegationActionType::RepoWrite,
Some(json!({
"action": "create",
"collection": input.collection,
"rkey": rkey
})),
None,
None,
)
.await;
}
(
StatusCode::OK,
Json(CreateRecordOutput {
@@ -415,6 +433,7 @@ pub async fn put_record(
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
let controller_did = auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
@@ -562,6 +581,7 @@ pub async fn put_record(
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>();
let is_update = existing_cid.is_some();
if let Err(e) = commit_and_log(
&state,
CommitParams {
@@ -582,6 +602,25 @@ pub async fn put_record(
)
.into_response();
};
if let Some(ref controller) = controller_did {
let _ = delegation::log_delegation_action(
&state.db,
&did,
controller,
Some(controller),
DelegationActionType::RepoWrite,
Some(json!({
"action": if is_update { "update" } else { "create" },
"collection": input.collection,
"rkey": input.rkey
})),
None,
None,
)
.await;
}
(
StatusCode::OK,
Json(PutRecordOutput {
+60 -12
View File
@@ -1,5 +1,6 @@
use crate::api::ApiError;
use crate::auth::BearerAuth;
use crate::delegation::{self, DelegationActionType};
use crate::state::{AppState, RateLimitKind};
use crate::util::get_user_id_by_did;
use axum::{
@@ -20,6 +21,8 @@ pub struct AppPassword {
pub privileged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by_controller: Option<String>,
}
#[derive(Serialize)]
@@ -36,7 +39,7 @@ pub async fn list_app_passwords(
Err(e) => return ApiError::from(e).into_response(),
};
match sqlx::query!(
"SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
"SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
user_id
)
.fetch_all(&state.db)
@@ -50,6 +53,7 @@ pub async fn list_app_passwords(
created_at: row.created_at.to_rfc3339(),
privileged: row.privileged,
scopes: row.scopes.clone(),
created_by_controller: row.created_by_controller_did.clone(),
})
.collect();
Json(ListAppPasswordsOutput { passwords }).into_response()
@@ -118,6 +122,31 @@ pub async fn create_app_password(
if let Ok(Some(_)) = existing {
return ApiError::DuplicateAppPassword.into_response();
}
let (final_scopes, controller_did) = if let Some(ref controller) = auth_user.controller_did {
let grant = delegation::get_delegation(&state.db, &auth_user.did, controller)
.await
.ok()
.flatten();
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
let requested = input.scopes.as_deref().unwrap_or("atproto");
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
if intersected.is_empty() && !granted_scopes.is_empty() {
return ApiError::InsufficientScope.into_response();
}
let scope_result = if intersected.is_empty() {
None
} else {
Some(intersected)
};
(scope_result, Some(controller.clone()))
} else {
(input.scopes.clone(), None)
};
let password: String = (0..4)
.map(|_| {
use rand::Rng;
@@ -137,28 +166,47 @@ pub async fn create_app_password(
}
};
let privileged = input.privileged.unwrap_or(false);
let scopes = input.scopes.clone();
let created_at = chrono::Utc::now();
match sqlx::query!(
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)",
user_id,
name,
password_hash,
created_at,
privileged,
scopes
final_scopes,
controller_did
)
.execute(&state.db)
.await
{
Ok(_) => Json(CreateAppPasswordOutput {
name: name.to_string(),
password,
created_at: created_at.to_rfc3339(),
privileged,
scopes,
})
.into_response(),
Ok(_) => {
if let Some(ref controller) = controller_did {
let _ = delegation::log_delegation_action(
&state.db,
&auth_user.did,
controller,
Some(controller),
DelegationActionType::AccountAction,
Some(json!({
"action": "create_app_password",
"name": name,
"scopes": final_scopes
})),
None,
None,
)
.await;
}
Json(CreateAppPasswordOutput {
name: name.to_string(),
password,
created_at: created_at.to_rfc3339(),
privileged,
scopes: final_scopes,
})
.into_response()
}
Err(e) => {
error!("DB error creating app password: {:?}", e);
ApiError::InternalError.into_response()
+1
View File
@@ -97,6 +97,7 @@ pub async fn get_service_auth(
is_admin: false,
scope: result.scope,
key_bytes: None,
controller_did: None,
},
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
return (
+21 -11
View File
@@ -125,16 +125,16 @@ pub async fn create_session(
return ApiError::InternalError.into_response();
}
};
let (password_valid, app_password_scopes) = if row
let (password_valid, app_password_scopes, app_password_controller) = if row
.password_hash
.as_ref()
.map(|h| verify(&input.password, h).unwrap_or(false))
.unwrap_or(false)
{
(true, None)
(true, None, None)
} else {
let app_passwords = sqlx::query!(
"SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
row.id
)
.fetch_all(&state.db)
@@ -144,8 +144,12 @@ pub async fn create_session(
.iter()
.find(|app| verify(&input.password, &app.password_hash).unwrap_or(false));
match matched {
Some(app) => (true, app.scopes.clone()),
None => (false, None),
Some(app) => (
true,
app.scopes.clone(),
app.created_by_controller_did.clone(),
),
None => (false, None, None),
}
};
if !password_valid {
@@ -155,7 +159,10 @@ pub async fn create_session(
}
let is_verified =
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
if !is_verified {
let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
warn!("Login attempt for unverified account: {}", row.did);
return (
StatusCode::FORBIDDEN,
@@ -181,10 +188,11 @@ pub async fn create_session(
)
.into_response();
}
let access_meta = match crate::auth::create_access_token_with_scope_metadata(
let access_meta = match crate::auth::create_access_token_with_delegation(
&row.did,
&key_bytes,
app_password_scopes.as_deref(),
app_password_controller.as_deref(),
) {
Ok(m) => m,
Err(e) => {
@@ -200,7 +208,7 @@ pub async fn create_session(
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
row.did,
access_meta.jti,
refresh_meta.jti,
@@ -208,7 +216,8 @@ pub async fn create_session(
refresh_meta.expires_at,
is_legacy_login,
false,
app_password_scopes
app_password_scopes,
app_password_controller
)
.execute(&state.db)
.await
@@ -397,7 +406,7 @@ pub async fn refresh_session(
.into_response();
}
let session_row = match sqlx::query!(
r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version
r#"SELECT st.id, st.did, st.scope, st.controller_did, k.key_bytes, k.encryption_version
FROM session_tokens st
JOIN users u ON st.did = u.did
JOIN user_keys k ON u.id = k.user_id
@@ -429,10 +438,11 @@ pub async fn refresh_session(
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
}
let new_access_meta = match crate::auth::create_access_token_with_scope_metadata(
let new_access_meta = match crate::auth::create_access_token_with_delegation(
&session_row.did,
&key_bytes,
session_row.scope.as_deref(),
session_row.controller_did.as_deref(),
) {
Ok(m) => m,
Err(e) => {
+15 -2
View File
@@ -24,8 +24,9 @@ pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_with_metadata, create_service_token,
create_access_token_with_delegation, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token,
create_refresh_token_with_metadata, create_service_token,
};
pub use verify::{
TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token,
@@ -62,6 +63,7 @@ pub struct AuthenticatedUser {
pub is_oauth: bool,
pub is_admin: bool,
pub scope: Option<String>,
pub controller_did: Option<String>,
}
impl AuthenticatedUser {
@@ -249,12 +251,14 @@ async fn validate_bearer_token_with_options_internal(
}
if session_valid {
let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone());
return Ok(AuthenticatedUser {
did: did.clone(),
key_bytes: Some(decrypted_key),
is_oauth: false,
is_admin,
scope: token_data.claims.scope.clone(),
controller_did,
});
}
}
@@ -304,6 +308,7 @@ async fn validate_bearer_token_with_options_internal(
is_oauth: true,
is_admin: oauth_token.is_admin,
scope: oauth_info.scope,
controller_did: oauth_info.controller_did,
});
} else {
return Err(TokenValidationError::TokenExpired);
@@ -378,12 +383,18 @@ pub async fn validate_token_with_dpop(
is_oauth: true,
is_admin: user_info.is_admin,
scope: result.scope,
controller_did: None,
})
}
Err(_) => Err(TokenValidationError::AuthenticationFailed),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActClaim {
pub sub: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub iss: String,
@@ -396,6 +407,8 @@ pub struct Claims {
#[serde(skip_serializing_if = "Option::is_none")]
pub lxm: Option<String>,
pub jti: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub act: Option<ActClaim>,
}
#[derive(Debug, Serialize, Deserialize)]
+34 -1
View File
@@ -1,4 +1,4 @@
use super::{Claims, Header};
use super::{ActClaim, Claims, Header};
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -51,6 +51,24 @@ pub fn create_access_token_with_scope_metadata(
)
}
pub fn create_access_token_with_delegation(
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
controller_did: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(SCOPE_ACCESS);
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
create_signed_token_with_act(
did,
scope,
TOKEN_TYPE_ACCESS,
key_bytes,
Duration::minutes(15),
act,
)
}
pub fn create_refresh_token_with_metadata(
did: &str,
key_bytes: &[u8],
@@ -81,6 +99,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
scope: None,
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
sign_claims(claims, &signing_key)
@@ -92,6 +111,17 @@ fn create_signed_token_with_metadata(
typ: &str,
key_bytes: &[u8],
duration: Duration,
) -> Result<TokenWithMetadata> {
create_signed_token_with_act(did, scope, typ, key_bytes, duration, None)
}
fn create_signed_token_with_act(
did: &str,
scope: &str,
typ: &str,
key_bytes: &[u8],
duration: Duration,
act: Option<ActClaim>,
) -> Result<TokenWithMetadata> {
let signing_key = SigningKey::from_slice(key_bytes)?;
@@ -114,6 +144,7 @@ fn create_signed_token_with_metadata(
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
act,
};
let token = sign_claims_with_type(claims, &signing_key, typ)?;
@@ -202,6 +233,7 @@ pub fn create_service_token_hs256(
scope: None,
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
@@ -233,6 +265,7 @@ fn create_hs256_token_with_metadata(
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
act: None,
};
let token = sign_claims_hs256(claims, typ, secret)?;
+142
View File
@@ -0,0 +1,142 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")]
pub enum DelegationActionType {
GrantCreated,
GrantRevoked,
ScopesModified,
TokenIssued,
RepoWrite,
BlobUpload,
AccountAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogEntry {
pub id: Uuid,
pub delegated_did: String,
pub actor_did: String,
pub controller_did: Option<String>,
pub action_type: DelegationActionType,
pub action_details: Option<serde_json::Value>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: DateTime<Utc>,
}
pub async fn log_delegation_action(
pool: &PgPool,
delegated_did: &str,
actor_did: &str,
controller_did: Option<&str>,
action_type: DelegationActionType,
action_details: Option<serde_json::Value>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<Uuid, sqlx::Error> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO delegation_audit_log
(delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
"#,
delegated_did,
actor_did,
controller_did,
action_type as DelegationActionType,
action_details,
ip_address,
user_agent
)
.fetch_one(pool)
.await?;
Ok(id)
}
pub async fn get_audit_log_for_account(
pool: &PgPool,
delegated_did: &str,
limit: i64,
offset: i64,
) -> Result<Vec<AuditLogEntry>, sqlx::Error> {
let entries = sqlx::query_as!(
AuditLogEntry,
r#"
SELECT
id,
delegated_did,
actor_did,
controller_did,
action_type as "action_type: DelegationActionType",
action_details,
ip_address,
user_agent,
created_at
FROM delegation_audit_log
WHERE delegated_did = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
delegated_did,
limit,
offset
)
.fetch_all(pool)
.await?;
Ok(entries)
}
pub async fn get_audit_log_by_controller(
pool: &PgPool,
controller_did: &str,
limit: i64,
offset: i64,
) -> Result<Vec<AuditLogEntry>, sqlx::Error> {
let entries = sqlx::query_as!(
AuditLogEntry,
r#"
SELECT
id,
delegated_did,
actor_did,
controller_did,
action_type as "action_type: DelegationActionType",
action_details,
ip_address,
user_agent,
created_at
FROM delegation_audit_log
WHERE controller_did = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
controller_did,
limit,
offset
)
.fetch_all(pool)
.await?;
Ok(entries)
}
pub async fn count_audit_log_entries(
pool: &PgPool,
delegated_did: &str,
) -> Result<i64, sqlx::Error> {
let count = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#,
delegated_did
)
.fetch_one(pool)
.await?;
Ok(count)
}
+267
View File
@@ -0,0 +1,267 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationGrant {
pub id: Uuid,
pub delegated_did: String,
pub controller_did: String,
pub granted_scopes: String,
pub granted_at: DateTime<Utc>,
pub granted_by: String,
pub revoked_at: Option<DateTime<Utc>>,
pub revoked_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegatedAccountInfo {
pub did: String,
pub handle: String,
pub granted_scopes: String,
pub granted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControllerInfo {
pub did: String,
pub handle: String,
pub granted_scopes: String,
pub granted_at: DateTime<Utc>,
pub is_active: bool,
}
pub async fn is_delegated_account(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query_scalar!(
r#"SELECT account_type::text = 'delegated' as "is_delegated!" FROM users WHERE did = $1"#,
did
)
.fetch_optional(pool)
.await?;
Ok(result.unwrap_or(false))
}
pub async fn create_delegation(
pool: &PgPool,
delegated_did: &str,
controller_did: &str,
granted_scopes: &str,
granted_by: &str,
) -> Result<Uuid, sqlx::Error> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
VALUES ($1, $2, $3, $4)
RETURNING id
"#,
delegated_did,
controller_did,
granted_scopes,
granted_by
)
.fetch_one(pool)
.await?;
Ok(id)
}
pub async fn revoke_delegation(
pool: &PgPool,
delegated_did: &str,
controller_did: &str,
revoked_by: &str,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE account_delegations
SET revoked_at = NOW(), revoked_by = $1
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
"#,
revoked_by,
delegated_did,
controller_did
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_delegation_scopes(
pool: &PgPool,
delegated_did: &str,
controller_did: &str,
new_scopes: &str,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
r#"
UPDATE account_delegations
SET granted_scopes = $1
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
"#,
new_scopes,
delegated_did,
controller_did
)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn get_delegation(
pool: &PgPool,
delegated_did: &str,
controller_did: &str,
) -> Result<Option<DelegationGrant>, sqlx::Error> {
let grant = sqlx::query_as!(
DelegationGrant,
r#"
SELECT id, delegated_did, controller_did, granted_scopes,
granted_at, granted_by, revoked_at, revoked_by
FROM account_delegations
WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL
"#,
delegated_did,
controller_did
)
.fetch_optional(pool)
.await?;
Ok(grant)
}
pub async fn get_delegations_for_account(
pool: &PgPool,
delegated_did: &str,
) -> Result<Vec<ControllerInfo>, sqlx::Error> {
let controllers = sqlx::query_as!(
ControllerInfo,
r#"
SELECT
u.did,
u.handle,
d.granted_scopes,
d.granted_at,
(u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!"
FROM account_delegations d
JOIN users u ON u.did = d.controller_did
WHERE d.delegated_did = $1 AND d.revoked_at IS NULL
ORDER BY d.granted_at DESC
"#,
delegated_did
)
.fetch_all(pool)
.await?;
Ok(controllers)
}
pub async fn get_accounts_controlled_by(
pool: &PgPool,
controller_did: &str,
) -> Result<Vec<DelegatedAccountInfo>, sqlx::Error> {
let accounts = sqlx::query_as!(
DelegatedAccountInfo,
r#"
SELECT
u.did,
u.handle,
d.granted_scopes,
d.granted_at
FROM account_delegations d
JOIN users u ON u.did = d.delegated_did
WHERE d.controller_did = $1
AND d.revoked_at IS NULL
AND u.deactivated_at IS NULL
AND u.takedown_ref IS NULL
ORDER BY d.granted_at DESC
"#,
controller_did
)
.fetch_all(pool)
.await?;
Ok(accounts)
}
pub async fn get_active_controllers_for_account(
pool: &PgPool,
delegated_did: &str,
) -> Result<Vec<ControllerInfo>, sqlx::Error> {
let controllers = sqlx::query_as!(
ControllerInfo,
r#"
SELECT
u.did,
u.handle,
d.granted_scopes,
d.granted_at,
true as "is_active!"
FROM account_delegations d
JOIN users u ON u.did = d.controller_did
WHERE d.delegated_did = $1
AND d.revoked_at IS NULL
AND u.deactivated_at IS NULL
AND u.takedown_ref IS NULL
ORDER BY d.granted_at DESC
"#,
delegated_did
)
.fetch_all(pool)
.await?;
Ok(controllers)
}
pub async fn count_active_controllers(
pool: &PgPool,
delegated_did: &str,
) -> Result<i64, sqlx::Error> {
let count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!"
FROM account_delegations d
JOIN users u ON u.did = d.controller_did
WHERE d.delegated_did = $1
AND d.revoked_at IS NULL
AND u.deactivated_at IS NULL
AND u.takedown_ref IS NULL
"#,
delegated_did
)
.fetch_one(pool)
.await?;
Ok(count)
}
pub async fn has_any_controllers(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let exists = sqlx::query_scalar!(
r#"SELECT EXISTS(
SELECT 1 FROM account_delegations
WHERE delegated_did = $1 AND revoked_at IS NULL
) as "exists!""#,
did
)
.fetch_one(pool)
.await?;
Ok(exists)
}
pub async fn controls_any_accounts(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let exists = sqlx::query_scalar!(
r#"SELECT EXISTS(
SELECT 1 FROM account_delegations
WHERE controller_did = $1 AND revoked_at IS NULL
) as "exists!""#,
did
)
.fetch_one(pool)
.await?;
Ok(exists)
}
+11
View File
@@ -0,0 +1,11 @@
pub mod audit;
pub mod db;
pub mod scopes;
pub use audit::{DelegationActionType, log_delegation_action};
pub use db::{
DelegationGrant, controls_any_accounts, create_delegation, get_accounts_controlled_by,
get_delegation, get_delegations_for_account, has_any_controllers, is_delegated_account,
revoke_delegation, update_delegation_scopes,
};
pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes};
+201
View File
@@ -0,0 +1,201 @@
use std::collections::HashSet;
pub struct ScopePreset {
pub name: &'static str,
pub label: &'static str,
pub description: &'static str,
pub scopes: &'static str,
}
pub const SCOPE_PRESETS: &[ScopePreset] = &[
ScopePreset {
name: "owner",
label: "Owner",
description: "Full control including delegation management",
scopes: "atproto",
},
ScopePreset {
name: "admin",
label: "Admin",
description: "Manage account settings, post content, upload media",
scopes: "atproto repo:* blob:*/* account:*?action=manage",
},
ScopePreset {
name: "editor",
label: "Editor",
description: "Post content and upload media",
scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
},
ScopePreset {
name: "viewer",
label: "Viewer",
description: "Read-only access",
scopes: "",
},
];
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
if granted.is_empty() {
return String::new();
}
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
let granted_has_atproto = granted_set.contains("atproto");
let requested_has_atproto = requested_set.contains("atproto");
if granted_has_atproto && requested_has_atproto {
return "atproto".to_string();
}
if granted_has_atproto {
return requested_set.into_iter().collect::<Vec<_>>().join(" ");
}
if requested_has_atproto {
return granted_set.into_iter().collect::<Vec<_>>().join(" ");
}
let mut result: Vec<&str> = Vec::new();
for requested_scope in &requested_set {
if granted_set.contains(requested_scope) {
result.push(requested_scope);
continue;
}
if let Some(match_result) = find_matching_scope(requested_scope, &granted_set) {
result.push(match_result);
}
}
result.sort();
result.join(" ")
}
fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> {
for granted_scope in granted {
if scopes_compatible(granted_scope, requested) {
return Some(granted_scope);
}
}
None
}
fn scopes_compatible(granted: &str, requested: &str) -> bool {
if granted == requested {
return true;
}
let (granted_base, _granted_params) = split_scope(granted);
let (requested_base, _requested_params) = split_scope(requested);
if granted_base.ends_with(":*")
&& requested_base.starts_with(&granted_base[..granted_base.len() - 1])
{
return true;
}
if granted_base.ends_with(".*") {
let prefix = &granted_base[..granted_base.len() - 2];
if requested_base.starts_with(prefix) && requested_base.len() > prefix.len() {
return true;
}
}
false
}
fn split_scope(scope: &str) -> (&str, Option<&str>) {
if let Some(idx) = scope.find('?') {
(&scope[..idx], Some(&scope[idx + 1..]))
} else {
(scope, None)
}
}
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> {
if scopes.is_empty() {
return Ok(());
}
for scope in scopes.split_whitespace() {
let (base, _) = split_scope(scope);
if !is_valid_scope_prefix(base) {
return Err(format!("Invalid scope: {}", scope));
}
}
Ok(())
}
fn is_valid_scope_prefix(base: &str) -> bool {
let valid_prefixes = [
"atproto",
"repo:",
"blob:",
"rpc:",
"account:",
"identity:",
"transition:",
];
for prefix in valid_prefixes {
if base == prefix.trim_end_matches(':') || base.starts_with(prefix) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intersect_both_atproto() {
assert_eq!(intersect_scopes("atproto", "atproto"), "atproto");
}
#[test]
fn test_intersect_granted_atproto() {
let result = intersect_scopes("repo:* blob:*/*", "atproto");
assert!(result.contains("repo:*"));
assert!(result.contains("blob:*/*"));
}
#[test]
fn test_intersect_requested_atproto() {
let result = intersect_scopes("atproto", "repo:* blob:*/*");
assert!(result.contains("repo:*"));
assert!(result.contains("blob:*/*"));
}
#[test]
fn test_intersect_exact_match() {
assert_eq!(
intersect_scopes("repo:*?action=create", "repo:*?action=create"),
"repo:*?action=create"
);
}
#[test]
fn test_intersect_empty_granted() {
assert_eq!(intersect_scopes("atproto", ""), "");
}
#[test]
fn test_validate_scopes_valid() {
assert!(validate_delegation_scopes("atproto").is_ok());
assert!(validate_delegation_scopes("repo:* blob:*/*").is_ok());
assert!(validate_delegation_scopes("").is_ok());
}
#[test]
fn test_validate_scopes_invalid() {
assert!(validate_delegation_scopes("invalid:scope").is_err());
}
}
+41
View File
@@ -6,6 +6,7 @@ pub mod circuit_breaker;
pub mod comms;
pub mod config;
pub mod crawlers;
pub mod delegation;
pub mod handle;
pub mod image;
pub mod metrics;
@@ -528,6 +529,14 @@ pub fn app(state: AppState) -> Router {
"/oauth/authorize/consent",
post(oauth::endpoints::consent_post),
)
.route(
"/oauth/delegation/auth",
post(oauth::endpoints::delegation_auth),
)
.route(
"/oauth/delegation/totp",
post(oauth::endpoints::delegation_totp_verify),
)
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
.route(
@@ -562,6 +571,38 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.verifyToken",
post(api::server::verify_token),
)
.route(
"/xrpc/com.tranquil.delegation.listControllers",
get(api::delegation::list_controllers),
)
.route(
"/xrpc/com.tranquil.delegation.addController",
post(api::delegation::add_controller),
)
.route(
"/xrpc/com.tranquil.delegation.removeController",
post(api::delegation::remove_controller),
)
.route(
"/xrpc/com.tranquil.delegation.updateControllerScopes",
post(api::delegation::update_controller_scopes),
)
.route(
"/xrpc/com.tranquil.delegation.listControlledAccounts",
get(api::delegation::list_controlled_accounts),
)
.route(
"/xrpc/com.tranquil.delegation.getAuditLog",
get(api::delegation::get_audit_log),
)
.route(
"/xrpc/com.tranquil.delegation.getScopePresets",
get(api::delegation::get_scope_presets),
)
.route(
"/xrpc/com.tranquil.delegation.createDelegatedAccount",
post(api::delegation::create_delegated_account),
)
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
+3 -3
View File
@@ -16,8 +16,8 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
pub use request::{
consume_authorization_request_by_code, create_authorization_request,
delete_authorization_request, delete_expired_authorization_requests, get_authorization_request,
mark_request_authenticated, set_authorization_did, update_authorization_request,
update_request_scope,
mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did,
update_authorization_request, update_request_scope,
};
pub use scope_preference::{
ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent,
@@ -27,7 +27,7 @@ pub use token::{
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
revoke_tokens_for_client, rotate_token,
revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token,
};
pub use two_factor::{
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+38 -2
View File
@@ -38,7 +38,7 @@ pub async fn get_authorization_request(
) -> Result<Option<RequestData>, OAuthError> {
let row = sqlx::query!(
r#"
SELECT did, device_id, client_id, client_auth, parameters, expires_at, code
SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did
FROM oauth_authorization_request
WHERE id = $1
"#,
@@ -61,6 +61,7 @@ pub async fn get_authorization_request(
did: r.did,
device_id: r.device_id,
code: r.code,
controller_did: r.controller_did,
}))
}
None => Ok(None),
@@ -119,7 +120,7 @@ pub async fn consume_authorization_request_by_code(
r#"
DELETE FROM oauth_authorization_request
WHERE code = $1
RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code
RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did
"#,
code
)
@@ -140,6 +141,7 @@ pub async fn consume_authorization_request_by_code(
did: r.did,
device_id: r.device_id,
code: r.code,
controller_did: r.controller_did,
}))
}
None => Ok(None),
@@ -212,3 +214,37 @@ pub async fn update_request_scope(
.await?;
Ok(())
}
pub async fn set_controller_did(
pool: &PgPool,
request_id: &str,
controller_did: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET controller_did = $2
WHERE id = $1
"#,
request_id,
controller_did
)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_request_did(pool: &PgPool, request_id: &str, did: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET did = $2
WHERE id = $1
"#,
request_id,
did
)
.execute(pool)
.await?;
Ok(())
}
+26 -6
View File
@@ -10,8 +10,8 @@ pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthE
r#"
INSERT INTO oauth_token
(did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
device_id, parameters, details, code, current_refresh_token, scope, controller_did)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
"#,
data.did,
@@ -27,6 +27,7 @@ pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthE
data.code,
data.current_refresh_token,
data.scope,
data.controller_did,
)
.fetch_one(pool)
.await?;
@@ -40,7 +41,7 @@ pub async fn get_token_by_id(
let row = sqlx::query!(
r#"
SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
device_id, parameters, details, code, current_refresh_token, scope, controller_did
FROM oauth_token
WHERE token_id = $1
"#,
@@ -63,6 +64,7 @@ pub async fn get_token_by_id(
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
controller_did: r.controller_did,
})),
None => Ok(None),
}
@@ -75,7 +77,7 @@ pub async fn get_token_by_refresh_token(
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
device_id, parameters, details, code, current_refresh_token, scope, controller_did
FROM oauth_token
WHERE current_refresh_token = $1
"#,
@@ -100,6 +102,7 @@ pub async fn get_token_by_refresh_token(
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
controller_did: r.controller_did,
},
))),
None => Ok(None),
@@ -178,7 +181,7 @@ pub async fn get_token_by_previous_refresh_token(
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
device_id, parameters, details, code, current_refresh_token, scope, controller_did
FROM oauth_token
WHERE previous_refresh_token = $1 AND rotated_at > $2
"#,
@@ -204,6 +207,7 @@ pub async fn get_token_by_previous_refresh_token(
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
controller_did: r.controller_did,
},
))),
None => Ok(None),
@@ -238,7 +242,7 @@ pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result<Vec<TokenD
let rows = sqlx::query!(
r#"
SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
device_id, parameters, details, code, current_refresh_token, scope, controller_did
FROM oauth_token
WHERE did = $1
"#,
@@ -262,6 +266,7 @@ pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result<Vec<TokenD
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
controller_did: r.controller_did,
});
}
Ok(tokens)
@@ -327,3 +332,18 @@ pub async fn revoke_tokens_for_client(
.await?;
Ok(result.rows_affected())
}
pub async fn revoke_tokens_for_controller(
pool: &PgPool,
delegated_did: &str,
controller_did: &str,
) -> Result<u64, OAuthError> {
let result = sqlx::query!(
"DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2",
delegated_did,
controller_did
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
+176 -8
View File
@@ -204,6 +204,55 @@ pub async fn authorize_get(
.into_response();
}
let force_new_account = query.new_account.unwrap_or(false);
if let Some(ref login_hint) = request_data.parameters.login_hint {
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized = if login_hint.contains('@') || login_hint.starts_with("did:") {
login_hint.clone()
} else if !login_hint.contains('.') {
format!("{}.{}", login_hint.to_lowercase(), pds_hostname)
} else {
login_hint.to_lowercase()
};
tracing::info!(normalized = %normalized, "Normalized login_hint");
match sqlx::query!(
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
normalized
)
.fetch_optional(&state.db)
.await
{
Ok(Some(user)) => {
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
let is_delegated = crate::delegation::is_delegated_account(&state.db, &user.did)
.await
.unwrap_or(false);
let has_password = user.password_hash.is_some();
tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check");
if is_delegated && !has_password {
tracing::info!("Redirecting to delegation auth");
return redirect_see_other(&format!(
"/#/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&request_uri),
url_encode(&user.did)
));
}
}
Ok(None) => {
tracing::info!(normalized = %normalized, "No user found for login_hint");
}
Err(e) => {
tracing::error!(error = %e, "Error looking up user for login_hint");
}
}
} else {
tracing::info!("No login_hint in request");
}
if !force_new_account
&& let Some(device_id) = extract_device_cookie(&headers)
&& let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
@@ -445,7 +494,8 @@ pub async fn authorize_post(
SELECT id, did, email, password_hash, password_required, two_factor_enabled,
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
deactivated_at, takedown_ref,
email_verified, discord_verified, telegram_verified, signal_verified
email_verified, discord_verified, telegram_verified, signal_verified,
account_type::text as "account_type!"
FROM users
WHERE handle = $1 OR email = $1
"#,
@@ -481,6 +531,32 @@ pub async fn authorize_post(
);
}
if user.account_type == "delegated" {
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = format!(
"/#/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&form.request_uri),
url_encode(&user.did)
);
if json_response {
return (
StatusCode::OK,
Json(serde_json::json!({
"next": "delegation",
"delegated_did": user.did,
"redirect": redirect_url
})),
)
.into_response();
}
return redirect_see_other(&redirect_url);
}
if !user.password_required {
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
@@ -1053,6 +1129,14 @@ pub struct ConsentResponse {
pub scopes: Vec<ScopeInfo>,
pub show_consent: bool,
pub did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_delegation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegation_level: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -1127,8 +1211,25 @@ pub async fn consent_get(
.parameters
.scope
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("atproto");
let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did {
crate::delegation::get_delegation(&state.db, &did, ctrl_did)
.await
.ok()
.flatten()
} else {
None
};
let effective_scope_str = if let Some(ref grant) = delegation_grant {
crate::delegation::scopes::intersect_scopes(requested_scope_str, &grant.granted_scopes)
} else {
requested_scope_str.to_string()
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let preferences =
db::get_scope_preferences(&state.db, &did, &request_data.parameters.client_id)
.await
@@ -1182,6 +1283,31 @@ pub async fn consent_get(
granted,
});
}
let (is_delegation, controller_did, controller_handle, delegation_level) =
if let Some(ref ctrl_did) = request_data.controller_did {
let ctrl_handle =
sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", ctrl_did)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let level = if let Some(ref grant) = delegation_grant {
let preset = crate::delegation::SCOPE_PRESETS
.iter()
.find(|p| p.scopes == grant.granted_scopes);
preset
.map(|p| p.label.to_string())
.unwrap_or_else(|| "Custom".to_string())
} else {
"Unknown".to_string()
};
(Some(true), Some(ctrl_did.clone()), ctrl_handle, Some(level))
} else {
(None, None, None, None)
};
Json(ConsentResponse {
request_uri: query.request_uri.clone(),
client_id: request_data.parameters.client_id.clone(),
@@ -1191,6 +1317,10 @@ pub async fn consent_get(
scopes,
show_consent,
did,
is_delegation,
controller_did,
controller_handle,
delegation_level,
})
.into_response()
}
@@ -1199,6 +1329,11 @@ pub async fn consent_post(
State(state): State<AppState>,
Json(form): Json<ConsentSubmit>,
) -> Response {
tracing::info!(
"consent_post: approved_scopes={:?}, remember={}",
form.approved_scopes,
form.remember
);
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
@@ -1246,12 +1381,28 @@ pub async fn consent_post(
.into_response();
}
};
let requested_scope_str = request_data
let original_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did {
crate::delegation::get_delegation(&state.db, &did, ctrl_did)
.await
.ok()
.flatten()
} else {
None
};
let effective_scope_str = if let Some(ref grant) = delegation_grant {
crate::delegation::scopes::intersect_scopes(original_scope_str, &grant.granted_scopes)
} else {
original_scope_str.to_string()
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let has_granular_scopes = requested_scopes.iter().any(|s| {
s.starts_with("repo:")
|| s.starts_with("blob:")
@@ -1640,6 +1791,10 @@ pub async fn check_user_has_passkeys(
pub struct SecurityStatusResponse {
pub has_passkeys: bool,
pub has_totp: bool,
pub has_password: bool,
pub is_delegated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub did: Option<String>,
}
pub async fn check_user_security_status(
@@ -1658,24 +1813,37 @@ pub async fn check_user_security_status(
};
let user = sqlx::query!(
"SELECT did FROM users WHERE handle = $1 OR email = $1",
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
normalized_identifier
)
.fetch_optional(&state.db)
.await;
let (has_passkeys, has_totp) = match user {
let (has_passkeys, has_totp, has_password, is_delegated, did): (
bool,
bool,
bool,
bool,
Option<String>,
) = match user {
Ok(Some(u)) => {
let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await;
let totp = crate::api::server::has_totp_enabled(&state, &u.did).await;
(passkeys, totp)
let has_pw = u.password_hash.is_some();
let has_controllers = crate::delegation::is_delegated_account(&state.db, &u.did)
.await
.unwrap_or(false);
(passkeys, totp, has_pw, has_controllers, Some(u.did))
}
_ => (false, false),
_ => (false, false, false, false, None),
};
Json(SecurityStatusResponse {
has_passkeys,
has_totp,
has_password,
is_delegated,
did,
})
.into_response()
}
+380
View File
@@ -0,0 +1,380 @@
use crate::delegation;
use crate::oauth::db;
use crate::state::{AppState, RateLimitKind};
use crate::util::extract_client_ip;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct DelegationAuthSubmit {
pub request_uri: String,
pub delegated_did: Option<String>,
pub controller_did: String,
pub password: String,
#[serde(default)]
pub remember_device: bool,
}
#[derive(Debug, Serialize)]
pub struct DelegationAuthResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub needs_totp: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn delegation_auth(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<DelegationAuthSubmit>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::Login, &client_ip)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Too many login attempts. Please try again later.".to_string()),
}),
)
.into_response();
}
let request = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(r)) => r,
Ok(None) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Authorization request not found".to_string()),
})
.into_response();
}
Err(_) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Server error".to_string()),
})
.into_response();
}
};
let delegated_did = match form.delegated_did.as_ref().or(request.did.as_ref()) {
Some(did) => did.clone(),
None => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("No delegated account selected".to_string()),
})
.into_response();
}
};
if let Err(_) = db::set_request_did(&state.db, &form.request_uri, &delegated_did).await {
tracing::warn!("Failed to set delegated DID on authorization request");
}
let grant =
match delegation::get_delegation(&state.db, &delegated_did, &form.controller_did).await {
Ok(Some(g)) => g,
Ok(None) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("No delegation grant found for this controller".to_string()),
})
.into_response();
}
Err(_) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Server error".to_string()),
})
.into_response();
}
};
let controller = match sqlx::query!(
r#"
SELECT id, did, password_hash, deactivated_at, takedown_ref,
email_verified, discord_verified, telegram_verified, signal_verified
FROM users
WHERE did = $1
"#,
form.controller_did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Controller account not found".to_string()),
})
.into_response();
}
Err(_) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Server error".to_string()),
})
.into_response();
}
};
if controller.deactivated_at.is_some() {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Controller account is deactivated".to_string()),
})
.into_response();
}
if controller.takedown_ref.is_some() {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Controller account has been taken down".to_string()),
})
.into_response();
}
let password_valid = match &controller.password_hash {
Some(hash) => match bcrypt::verify(&form.password, hash) {
Ok(valid) => valid,
Err(_) => false,
},
None => false,
};
if !password_valid {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Invalid password".to_string()),
})
.into_response();
}
if let Err(_) = db::set_controller_did(&state.db, &form.request_uri, &form.controller_did).await
{
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Failed to update authorization request".to_string()),
})
.into_response();
}
let has_totp = crate::api::server::has_totp_enabled(&state, &form.controller_did).await;
if has_totp {
return Json(DelegationAuthResponse {
success: true,
needs_totp: Some(true),
redirect_uri: Some(format!(
"/#/oauth/delegation-totp?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
})
.into_response();
}
let ip = extract_client_ip(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let _ = delegation::log_delegation_action(
&state.db,
&delegated_did,
&form.controller_did,
Some(&form.controller_did),
delegation::DelegationActionType::TokenIssued,
Some(serde_json::json!({
"client_id": request.client_id,
"granted_scopes": grant.granted_scopes
})),
Some(&ip),
user_agent.as_deref(),
)
.await;
Json(DelegationAuthResponse {
success: true,
needs_totp: None,
redirect_uri: Some(format!(
"/#/oauth/consent?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
})
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct DelegationTotpSubmit {
pub request_uri: String,
pub code: String,
}
pub async fn delegation_totp_verify(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<DelegationTotpSubmit>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &client_ip)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Too many verification attempts. Please try again later.".to_string()),
}),
)
.into_response();
}
let request = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(r)) => r,
Ok(None) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Authorization request not found".to_string()),
})
.into_response();
}
Err(_) => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Server error".to_string()),
})
.into_response();
}
};
let controller_did = match &request.controller_did {
Some(did) => did.clone(),
None => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Controller not authenticated".to_string()),
})
.into_response();
}
};
let delegated_did = match &request.did {
Some(did) => did.clone(),
None => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("No delegated account".to_string()),
})
.into_response();
}
};
let grant = match delegation::get_delegation(&state.db, &delegated_did, &controller_did).await {
Ok(Some(g)) => g,
_ => {
return Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Delegation grant not found".to_string()),
})
.into_response();
}
};
let totp_valid =
crate::api::server::verify_totp_or_backup_for_user(&state, &controller_did, &form.code)
.await;
if !totp_valid {
return Json(DelegationAuthResponse {
success: false,
needs_totp: Some(true),
redirect_uri: None,
error: Some("Invalid TOTP code".to_string()),
})
.into_response();
}
let ip = extract_client_ip(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let _ = delegation::log_delegation_action(
&state.db,
&delegated_did,
&controller_did,
Some(&controller_did),
delegation::DelegationActionType::TokenIssued,
Some(serde_json::json!({
"client_id": request.client_id,
"granted_scopes": grant.granted_scopes
})),
Some(&ip),
user_agent.as_deref(),
)
.await;
Json(DelegationAuthResponse {
success: true,
needs_totp: None,
redirect_uri: Some(format!(
"/#/oauth/consent?request_uri={}",
urlencoding::encode(&form.request_uri)
)),
error: None,
})
.into_response()
}
+2
View File
@@ -1,9 +1,11 @@
pub mod authorize;
pub mod delegation;
pub mod metadata;
pub mod par;
pub mod token;
pub use authorize::*;
pub use delegation::*;
pub use metadata::*;
pub use par::*;
pub use token::*;
+5 -2
View File
@@ -58,8 +58,10 @@ pub async fn pushed_authorization_request(
serde_json::from_slice(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
} else if content_type.starts_with("application/x-www-form-urlencoded") {
serde_urlencoded::from_bytes(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?
let parsed: ParRequest = serde_urlencoded::from_bytes(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?;
tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)");
parsed
} else {
return Err(OAuthError::InvalidRequest(
"Content-Type must be application/json or application/x-www-form-urlencoded"
@@ -128,6 +130,7 @@ pub async fn pushed_authorization_request(
did: None,
device_id: None,
code: None,
controller_did: None,
};
db::create_authorization_request(&state.db, &request_id.0, &request_data).await?;
tokio::spawn({
+30 -7
View File
@@ -1,6 +1,7 @@
use super::helpers::{create_access_token, verify_pkce};
use super::helpers::{create_access_token_with_delegation, verify_pkce};
use super::types::{TokenRequest, TokenResponse};
use crate::config::AuthConfig;
use crate::delegation;
use crate::oauth::{
ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
client::{ClientMetadataCache, verify_client_auth},
@@ -106,11 +107,30 @@ pub async fn handle_authorization_code_grant(
let token_id = TokenId::generate();
let refresh_token = RefreshToken::generate();
let now = Utc::now();
let access_token = create_access_token(
let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did {
let grant = delegation::get_delegation(&state.db, &did, controller)
.await
.ok()
.flatten();
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
let requested = auth_request
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
(Some(intersected), Some(controller.clone()))
} else {
(auth_request.parameters.scope.clone(), None)
};
let access_token = create_access_token_with_delegation(
&token_id.0,
&did,
dpop_jkt.as_deref(),
auth_request.parameters.scope.as_deref(),
final_scope.as_deref(),
controller_did.as_deref(),
)?;
let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None);
let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) {
@@ -131,7 +151,8 @@ pub async fn handle_authorization_code_grant(
details: None,
code: None,
current_refresh_token: Some(refresh_token.0.clone()),
scope: auth_request.parameters.scope.clone(),
scope: final_scope.clone(),
controller_did: controller_did.clone(),
};
db::create_token(&state.db, &token_data).await?;
tokio::spawn({
@@ -154,7 +175,7 @@ pub async fn handle_authorization_code_grant(
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
refresh_token: Some(refresh_token.0),
scope: auth_request.parameters.scope,
scope: final_scope,
sub: Some(did),
}),
))
@@ -183,11 +204,12 @@ pub async fn handle_refresh_token_grant(
"Refresh token reuse within grace period, returning existing tokens"
);
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
let access_token = create_access_token(
let access_token = create_access_token_with_delegation(
&token_data.token_id,
&token_data.did,
dpop_jkt,
token_data.scope.as_deref(),
token_data.controller_did.as_deref(),
)?;
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
@@ -282,11 +304,12 @@ pub async fn handle_refresh_token_grant(
new_expires_at = %new_expires_at,
"Refresh token rotated successfully"
);
let access_token = create_access_token(
let access_token = create_access_token_with_delegation(
&new_token_id.0,
&token_data.did,
dpop_jkt.as_deref(),
token_data.scope.as_deref(),
token_data.controller_did.as_deref(),
)?;
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
+13
View File
@@ -37,6 +37,16 @@ pub fn create_access_token(
sub: &str,
dpop_jkt: Option<&str>,
scope: Option<&str>,
) -> Result<String, OAuthError> {
create_access_token_with_delegation(token_id, sub, dpop_jkt, scope, None)
}
pub fn create_access_token_with_delegation(
token_id: &str,
sub: &str,
dpop_jkt: Option<&str>,
scope: Option<&str>,
controller_did: Option<&str>,
) -> Result<String, OAuthError> {
use serde_json::json;
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -56,6 +66,9 @@ pub fn create_access_token(
if let Some(jkt) = dpop_jkt {
payload["cnf"] = json!({ "jkt": jkt });
}
if let Some(controller) = controller_did {
payload["act"] = json!({ "sub": controller });
}
let header = json!({
"alg": "HS256",
"typ": "at+jwt"
+16 -2
View File
@@ -40,8 +40,8 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
scope: "atproto",
category: ScopeCategory::Core,
required: true,
description: "Use AT Protocol OAuth (required for all sessions)",
display_name: "AT Protocol",
description: "Full access to read, write, and manage this account",
display_name: "Full Account Access",
},
ScopeDefinition {
scope: "transition:generic",
@@ -92,6 +92,20 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
description: "Upload images, videos, and other media files",
display_name: "Upload Media",
},
ScopeDefinition {
scope: "repo:*",
category: ScopeCategory::Repo,
required: false,
description: "Full read and write access to all repository records",
display_name: "Full Repository Access",
},
ScopeDefinition {
scope: "account:*?action=manage",
category: ScopeCategory::Account,
required: false,
description: "Manage account settings and preferences",
display_name: "Manage Account",
},
];
definitions.into_iter().map(|d| (d.scope, d)).collect()
+2
View File
@@ -107,6 +107,7 @@ pub struct RequestData {
pub did: Option<String>,
pub device_id: Option<String>,
pub code: Option<String>,
pub controller_did: Option<String>,
}
#[derive(Debug, Clone)]
@@ -132,6 +133,7 @@ pub struct TokenData {
pub code: Option<String>,
pub current_refresh_token: Option<String>,
pub scope: Option<String>,
pub controller_did: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+7
View File
@@ -24,6 +24,7 @@ pub struct OAuthTokenInfo {
pub client_id: String,
pub scope: Option<String>,
pub dpop_jkt: Option<String>,
pub controller_did: Option<String>,
}
pub struct VerifyResult {
@@ -148,12 +149,18 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
.and_then(|c| c.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let controller_did = payload
.get("act")
.and_then(|a| a.get("sub"))
.and_then(|s| s.as_str())
.map(|s| s.to_string());
Ok(OAuthTokenInfo {
did,
token_id,
client_id,
scope,
dpop_jkt,
controller_did,
})
}
+16
View File
@@ -1,3 +1,4 @@
use axum::http::HeaderMap;
use rand::Rng;
use sqlx::PgPool;
use uuid::Uuid;
@@ -72,6 +73,21 @@ pub async fn get_user_by_identifier(
.ok_or(DbLookupError::NotFound)
}
pub fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
+41
View File
@@ -382,6 +382,32 @@ pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> {
Ok(())
}
pub fn is_valid_did(did: &str) -> bool {
if !did.starts_with("did:") {
return false;
}
let parts: Vec<&str> = did.splitn(3, ':').collect();
if parts.len() < 3 {
return false;
}
let method = parts[1];
if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) {
return false;
}
let id = parts[2];
!id.is_empty()
}
pub fn validate_did(did: &str) -> Result<(), ValidationError> {
if !is_valid_did(did) {
return Err(ValidationError::InvalidField {
path: "did".to_string(),
message: "Invalid DID format".to_string(),
});
}
Ok(())
}
pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> {
if collection.is_empty() {
return Err(ValidationError::InvalidRecord(
@@ -604,4 +630,19 @@ mod tests {
assert!(validate_collection_nsid("a.b").is_err());
assert!(validate_collection_nsid("").is_err());
}
#[test]
fn test_is_valid_did() {
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
assert!(is_valid_did("did:web:example.com"));
assert!(is_valid_did(
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
));
assert!(!is_valid_did(""));
assert!(!is_valid_did("plc:1234567890abcdefghijk"));
assert!(!is_valid_did("did:"));
assert!(!is_valid_did("did:plc:"));
assert!(!is_valid_did("did::something"));
assert!(!is_valid_did("DID:plc:test"));
}
}