mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 01:04:14 +00:00
Gearing up for migration endpoints
This commit is contained in:
@@ -37,9 +37,9 @@ Lewis' corrected big boy todofile
|
||||
- [x] Implement `com.atproto.server.requestAccountDelete`.
|
||||
- [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
|
||||
- [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
|
||||
- [ ] Implement `com.atproto.server.reserveSigningKey`.
|
||||
- [x] Implement `com.atproto.server.reserveSigningKey`.
|
||||
- [x] Implement `com.atproto.server.revokeAppPassword`.
|
||||
- [ ] Implement `com.atproto.server.updateEmail`.
|
||||
- [x] Implement `com.atproto.server.updateEmail`.
|
||||
- [x] Implement `com.atproto.server.confirmEmail`.
|
||||
|
||||
## Repository Operations (`com.atproto.repo`)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
did TEXT,
|
||||
public_key_did_key TEXT NOT NULL,
|
||||
private_key_bytes BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
|
||||
used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL;
|
||||
@@ -17,13 +17,14 @@ use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountInput {
|
||||
pub handle: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[serde(rename = "inviteCode")]
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -185,12 +186,55 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
|
||||
let secret_key = SecretKey::random(&mut OsRng);
|
||||
let secret_key_bytes = secret_key.to_bytes();
|
||||
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
|
||||
if let Some(signing_key_did) = &input.signing_key {
|
||||
let reserved = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, private_key_bytes
|
||||
FROM reserved_signing_keys
|
||||
WHERE public_key_did_key = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
FOR UPDATE
|
||||
"#,
|
||||
signing_key_did
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
let key_insert = sqlx::query!("INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", user_id, &secret_key_bytes[..])
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
match reserved {
|
||||
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidSigningKey",
|
||||
"message": "Signing key not found, already used, or expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error looking up reserved signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let secret_key = SecretKey::random(&mut OsRng);
|
||||
(secret_key.to_bytes().to_vec(), None)
|
||||
};
|
||||
|
||||
let key_insert = sqlx::query!(
|
||||
"INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)",
|
||||
user_id,
|
||||
&secret_key_bytes[..]
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if let Err(e) = key_insert {
|
||||
error!("Error inserting user key: {:?}", e);
|
||||
@@ -201,6 +245,24 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(key_id) = reserved_key_id {
|
||||
let mark_used = sqlx::query!(
|
||||
"UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1",
|
||||
key_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if let Err(e) = mark_used {
|
||||
error!("Error marking reserved key as used: {:?}", 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,
|
||||
|
||||
@@ -286,3 +286,212 @@ pub async fn confirm_email(
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateEmailInput {
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub email_auth_factor: Option<bool>,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Response {
|
||||
let auth_header = headers.get("Authorization");
|
||||
if auth_header.is_none() {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token = auth_header
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.replace("Bearer ", "");
|
||||
|
||||
let session = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email,
|
||||
u.email_confirmation_code, u.email_confirmation_code_expires_at,
|
||||
u.email_pending_verification
|
||||
FROM sessions s
|
||||
JOIN users u ON s.did = u.did
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE s.access_jwt = $1
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let (
|
||||
_did,
|
||||
key_bytes,
|
||||
user_id,
|
||||
current_email,
|
||||
stored_code,
|
||||
expires_at,
|
||||
email_pending_verification,
|
||||
) = match session {
|
||||
Ok(Some(row)) => (
|
||||
row.did,
|
||||
row.key_bytes,
|
||||
row.user_id,
|
||||
row.current_email,
|
||||
row.email_confirmation_code,
|
||||
row.email_confirmation_code_expires_at,
|
||||
row.email_pending_verification,
|
||||
),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in update_email: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let new_email = input.email.trim().to_lowercase();
|
||||
if new_email.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !new_email.contains('@') || !new_email.contains('.') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if new_email == current_email.to_lowercase() {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
|
||||
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
|
||||
|
||||
if email_confirmed {
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => t.trim(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "TokenRequired", "message": "Token required for confirmed accounts. Call requestEmailUpdate first."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pending_email = email_pending_verification.unwrap();
|
||||
if pending_email.to_lowercase() != new_email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if stored_code.unwrap() != confirmation_token {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(exp) = expires_at {
|
||||
if Utc::now() > exp {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exists = sqlx::query!(
|
||||
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
|
||||
new_email,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(_)) = exists {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let update = sqlx::query!(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET email = $1,
|
||||
email_pending_verification = NULL,
|
||||
email_confirmation_code = NULL,
|
||||
email_confirmation_code_expires_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
"#,
|
||||
new_email,
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match update {
|
||||
Ok(_) => {
|
||||
info!("Email updated to {} for user {}", new_email, user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error finalizing email update: {:?}", e);
|
||||
if e.as_database_error()
|
||||
.map(|db_err| db_err.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ pub mod invite;
|
||||
pub mod meta;
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
pub mod signing_key;
|
||||
|
||||
pub use account_status::{
|
||||
activate_account, check_account_status, deactivate_account, request_account_delete,
|
||||
};
|
||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||
pub use email::{confirm_email, request_email_update};
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use meta::{describe_server, health};
|
||||
pub use password::{request_password_reset, reset_password};
|
||||
pub use session::{
|
||||
create_session, delete_session, get_service_auth, get_session, refresh_session,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let compressed_pubkey = verifying_key.to_sec1_bytes();
|
||||
|
||||
let mut multicodec_key = Vec::with_capacity(2 + compressed_pubkey.len());
|
||||
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
|
||||
multicodec_key.extend_from_slice(&compressed_pubkey);
|
||||
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
|
||||
format!("did:key:{}", encoded)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReserveSigningKeyInput {
|
||||
pub did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReserveSigningKeyOutput {
|
||||
pub signing_key: String,
|
||||
}
|
||||
|
||||
pub async fn reserve_signing_key(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ReserveSigningKeyInput>,
|
||||
) -> Response {
|
||||
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
||||
let private_key_bytes = signing_key.to_bytes();
|
||||
let public_key_did_key = public_key_to_did_key(&signing_key);
|
||||
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
|
||||
let private_bytes: &[u8] = &private_key_bytes;
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO reserved_signing_keys (did, public_key_did_key, private_key_bytes, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
"#,
|
||||
input.did,
|
||||
public_key_did_key,
|
||||
private_bytes,
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(row) => {
|
||||
info!(
|
||||
"Reserved signing key {} for did {:?}",
|
||||
row.id,
|
||||
input.did
|
||||
);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ReserveSigningKeyOutput {
|
||||
signing_key: public_key_did_key,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in reserve_signing_key: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.server.confirmEmail",
|
||||
post(api::server::confirm_email),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.updateEmail",
|
||||
post(api::server::update_email),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.reserveSigningKey",
|
||||
post(api::server::reserve_signing_key),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.identity.updateHandle",
|
||||
post(api::identity::update_handle),
|
||||
|
||||
@@ -234,3 +234,327 @@ async fn test_confirm_email_wrong_email() {
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert_eq!(body["message"], "Email does not match pending update");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_success_no_token_required() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
let pool = get_pool().await;
|
||||
|
||||
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let new_email = format!("direct_{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({ "email": new_email }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to update email");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("User not found");
|
||||
assert_eq!(user.email, new_email);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_same_email_noop() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle = format!("emailup_same_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({ "email": email }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to update email");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK, "Updating to same email should succeed as no-op");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_requires_token_after_pending() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle = format!("emailup_token_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let new_email = format!("pending_{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({"email": new_email}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to request email update");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({ "email": new_email }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to attempt email update");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert_eq!(body["error"], "TokenRequired");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_with_valid_token() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
let pool = get_pool().await;
|
||||
|
||||
let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let new_email = format!("valid_{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({"email": new_email}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to request email update");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT email_confirmation_code FROM users WHERE handle = $1",
|
||||
handle
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("User not found");
|
||||
let code = user.email_confirmation_code.unwrap();
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({
|
||||
"email": new_email,
|
||||
"token": code
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to update email");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let user = sqlx::query!("SELECT email, email_pending_verification FROM users WHERE handle = $1", handle)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("User not found");
|
||||
assert_eq!(user.email, new_email);
|
||||
assert!(user.email_pending_verification.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_invalid_token() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let new_email = format!("badtok_{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({"email": new_email}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to request email update");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({
|
||||
"email": new_email,
|
||||
"token": "wrong-token-12345"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to attempt email update");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert_eq!(body["error"], "InvalidToken");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_already_taken() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4());
|
||||
let email1 = format!("{}@example.com", handle1);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle1,
|
||||
"email": email1,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account 1");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4());
|
||||
let email2 = format!("{}@example.com", handle2);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle2,
|
||||
"email": email2,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account 2");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt2)
|
||||
.json(&json!({ "email": email1 }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to attempt email update");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert!(body["message"].as_str().unwrap().contains("already in use") || body["error"] == "InvalidRequest");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_no_auth() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.json(&json!({ "email": "test@example.com" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert_eq!(body["error"], "AuthenticationRequired");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_email_invalid_format() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&json!({ "email": "not-an-email" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
assert_eq!(body["error"], "InvalidRequest");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
mod common;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn get_pool() -> PgPool {
|
||||
let conn_str = common::get_db_connection_string().await;
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&conn_str)
|
||||
.await
|
||||
.expect("Failed to connect to test database")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve_signing_key_without_did() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
|
||||
assert!(body["signingKey"].is_string());
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
assert!(
|
||||
signing_key.starts_with("did:key:z"),
|
||||
"Signing key should be in did:key format with multibase prefix"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve_signing_key_with_did() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
let pool = get_pool().await;
|
||||
|
||||
let target_did = "did:plc:test123456";
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({ "did": target_did }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
assert!(signing_key.starts_with("did:key:z"));
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
signing_key
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Reserved key not found in database");
|
||||
|
||||
assert_eq!(row.did.as_deref(), Some(target_did));
|
||||
assert_eq!(row.public_key_did_key, signing_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve_signing_key_stores_private_key() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
let pool = get_pool().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
signing_key
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Reserved key not found in database");
|
||||
|
||||
assert_eq!(row.private_key_bytes.len(), 32, "Private key should be 32 bytes for secp256k1");
|
||||
assert!(row.used_at.is_none(), "Reserved key should not be marked as used yet");
|
||||
assert!(row.expires_at > chrono::Utc::now(), "Key should expire in the future");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve_signing_key_unique_keys() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res1 = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request 1");
|
||||
assert_eq!(res1.status(), StatusCode::OK);
|
||||
let body1: Value = res1.json().await.unwrap();
|
||||
let key1 = body1["signingKey"].as_str().unwrap();
|
||||
|
||||
let res2 = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request 2");
|
||||
assert_eq!(res2.status(), StatusCode::OK);
|
||||
let body2: Value = res2.json().await.unwrap();
|
||||
let key2 = body2["signingKey"].as_str().unwrap();
|
||||
|
||||
assert_ne!(key1, key2, "Each call should generate a unique signing key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve_signing_key_is_public() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"reserveSigningKey should work without authentication"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_account_with_reserved_signing_key() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
let pool = get_pool().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
|
||||
let handle = format!("reserved_key_user_{}", uuid::Uuid::new_v4());
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert!(body["accessJwt"].is_string());
|
||||
assert!(body["did"].is_string());
|
||||
|
||||
let reserved = sqlx::query!(
|
||||
"SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
signing_key
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Reserved key not found");
|
||||
assert!(
|
||||
reserved.used_at.is_some(),
|
||||
"Reserved key should be marked as used"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_account_with_invalid_signing_key() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let handle = format!("bad_key_user_{}", uuid::Uuid::new_v4());
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"signingKey": "did:key:zNonExistentKey12345"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"], "InvalidSigningKey");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_account_cannot_reuse_signing_key() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
|
||||
let handle1 = format!("reuse_key_user1_{}", uuid::Uuid::new_v4());
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle1,
|
||||
"email": format!("{}@example.com", handle1),
|
||||
"password": "password",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create first account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let handle2 = format!("reuse_key_user2_{}", uuid::Uuid::new_v4());
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle2,
|
||||
"email": format!("{}@example.com", handle2),
|
||||
"password": "password",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send second request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"], "InvalidSigningKey");
|
||||
assert!(body["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("already used"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserved_key_tokens_work() {
|
||||
let client = common::client();
|
||||
let base_url = common::base_url().await;
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let signing_key = body["signingKey"].as_str().unwrap();
|
||||
|
||||
let handle = format!("token_test_user_{}", uuid::Uuid::new_v4());
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create account");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let access_jwt = body["accessJwt"].as_str().unwrap();
|
||||
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.server.getSession",
|
||||
base_url
|
||||
))
|
||||
.bearer_auth(access_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get session");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["handle"], handle);
|
||||
}
|
||||
Reference in New Issue
Block a user