An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement requestPasswordReset and resetPassword XRPC endpoints

Adds the two ATProto password reset endpoints (MM-156):

- POST /xrpc/com.atproto.server.requestPasswordReset — accepts an email,
generates a 1-hour single-use token stored as SHA-256 hash, and always
returns 200 regardless of whether the email exists (prevents enumeration).
Email delivery is stubbed as tracing::info\! for v0.1.

- POST /xrpc/com.atproto.server.resetPassword — validates the token,
hashes the new password with argon2id, and atomically marks the token
used and updates accounts.password_hash. Returns 401 InvalidToken for
unknown tokens and 400 ExpiredToken for expired or already-used tokens.

Also adds ErrorCode::ExpiredToken (HTTP 400, serialised as PascalCase
"ExpiredToken") to match the ATProto resetPassword lexicon exactly, and
V014 migration for the password_reset_tokens table.

authored by

Malpercio and committed by
Tangled
414caf09 f36185f6

+733
+21
bruno/request_password_reset.bru
··· 1 + meta { 2 + name: Request Password Reset (requestPasswordReset) 3 + type: http 4 + seq: 26 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/xrpc/com.atproto.server.requestPasswordReset 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "email": "user@example.com" 16 + } 17 + } 18 + 19 + vars:pre-request { 20 + baseUrl: http://localhost:8080 21 + }
+22
bruno/reset_password.bru
··· 1 + meta { 2 + name: Reset Password (resetPassword) 3 + type: http 4 + seq: 27 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/xrpc/com.atproto.server.resetPassword 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "token": "{{resetToken}}", 16 + "password": "newpassword123" 17 + } 18 + } 19 + 20 + vars:pre-request { 21 + baseUrl: http://localhost:8080 22 + }
+15
crates/common/src/error.rs
··· 50 50 AuthenticationRequired, 51 51 /// Token is structurally invalid, has wrong signature, wrong audience, or DPoP mismatch. 52 52 InvalidToken, 53 + /// A password-reset token has expired or has already been used. 54 + /// 55 + /// Serialized as `"ExpiredToken"` (PascalCase) to match the AT Protocol XRPC error format 56 + /// for `com.atproto.server.resetPassword`. 57 + #[serde(rename = "ExpiredToken")] 58 + ExpiredToken, 53 59 // TODO: add remaining codes from Appendix A as endpoints are implemented: 54 60 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 55 61 // 401: INVALID_CREDENTIALS ··· 87 93 ErrorCode::HandleNotFound => 404, 88 94 ErrorCode::AuthenticationRequired => 401, 89 95 ErrorCode::InvalidToken => 401, 96 + ErrorCode::ExpiredToken => 400, 90 97 } 91 98 } 92 99 } ··· 214 221 } 215 222 216 223 #[test] 224 + fn expired_token_serializes_as_pascal_case() { 225 + let err = ApiError::new(ErrorCode::ExpiredToken, "token has expired"); 226 + let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 227 + assert_eq!(actual["error"]["code"], "ExpiredToken"); 228 + } 229 + 230 + #[test] 217 231 fn omits_details_when_absent() { 218 232 let err = ApiError::new(ErrorCode::Forbidden, "access denied"); 219 233 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); ··· 244 258 (ErrorCode::HandleNotFound, 404), 245 259 (ErrorCode::AuthenticationRequired, 401), 246 260 (ErrorCode::InvalidToken, 401), 261 + (ErrorCode::ExpiredToken, 400), 247 262 ]; 248 263 for (code, expected) in cases { 249 264 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
+3
crates/relay/CLAUDE.md
··· 45 45 | `mod.rs` | `open_pool`, `run_migrations`, `DbError`, `is_unique_violation` | 46 46 | `accounts.rs` | `AccountRow`, `resolve_identifier` | 47 47 | `oauth.rs` | OAuth client lookup, auth code storage, token management | 48 + | `password_reset.rs` | `insert_reset_token`, `get_reset_token`, `mark_reset_token_used`, `update_password_hash` | 48 49 49 50 See [`src/db/CLAUDE.md`](src/db/CLAUDE.md) for migration history and invariants. 50 51 ··· 68 69 | `create_session.rs` | `POST /xrpc/com.atproto.server.createSession` | 69 70 | `get_session.rs` | `GET /xrpc/com.atproto.server.getSession` | 70 71 | `refresh_session.rs` | `POST /xrpc/com.atproto.server.refreshSession` | 72 + | `request_password_reset.rs` | `POST /xrpc/com.atproto.server.requestPasswordReset` | 73 + | `reset_password.rs` | `POST /xrpc/com.atproto.server.resetPassword` | 71 74 | `create_did.rs` | `POST /v1/dids` | 72 75 | `get_did.rs` | `GET /v1/dids/:did` | 73 76 | `create_account.rs` | `POST /v1/accounts` |
+10
crates/relay/src/app.rs
··· 39 39 use crate::routes::provisioning_session::create_provisioning_session; 40 40 use crate::routes::refresh_session::refresh_session; 41 41 use crate::routes::register_device::register_device; 42 + use crate::routes::request_password_reset::request_password_reset; 43 + use crate::routes::reset_password::reset_password; 42 44 use crate::routes::resolve_handle::resolve_handle_handler; 43 45 use crate::well_known::WellKnownResolver; 44 46 ··· 171 173 .route( 172 174 "/xrpc/com.atproto.server.deleteSession", 173 175 post(delete_session), 176 + ) 177 + .route( 178 + "/xrpc/com.atproto.server.requestPasswordReset", 179 + post(request_password_reset), 180 + ) 181 + .route( 182 + "/xrpc/com.atproto.server.resetPassword", 183 + post(reset_password), 174 184 ) 175 185 .route( 176 186 "/xrpc/com.atproto.identity.resolveHandle",
+2
crates/relay/src/db/CLAUDE.md
··· 3 3 Last verified: 2026-03-25 4 4 5 5 ## Latest Updates 6 + - **V014**: Adds `password_reset_tokens` table: `token_hash` TEXT PK (SHA-256 hex digest — plaintext never stored), `did` TEXT (FK→accounts), `expires_at` TEXT (1-hour TTL, SQLite datetime), `used_at` TEXT nullable (set on consumption), `created_at` TEXT; index on `did` 6 7 - **V013**: Seeds the identity-wallet as a registered OAuth client (`dev.malpercio.identitywallet`) with native application type, DPoP-bound tokens, and custom URL scheme redirect URI (`dev.malpercio.identitywallet:/oauth/callback`); uses INSERT OR IGNORE for idempotency 7 8 - **V012**: Adds nullable `jkt` TEXT column to `oauth_tokens` (DPoP key thumbprint for DPoP-bound refresh tokens); creates `oauth_signing_key` table (WITHOUT ROWID, single-row, stores the server's persistent ES256 keypair with AES-256-GCM-encrypted private key) 8 9 - **V011**: Adds nullable `pending_share_{1,2,3}` TEXT columns to `pending_accounts` — stores pre-generated Shamir shares alongside `pending_did` so retried DID ceremony requests return the same shares (prevents Share 2 orphaning in accounts.recovery_share) ··· 53 54 - `migrations/V011__pending_shares.sql` - Adds nullable pending_share_{1,2,3} TEXT columns to pending_accounts: idempotent share storage alongside pending_did; all three deleted when pending_accounts row is deleted at promotion 54 55 - `migrations/V012__oauth_token_endpoint.sql` - Adds `jkt` TEXT column to oauth_tokens (DPoP thumbprint); creates `oauth_signing_key` table (WITHOUT ROWID, keyed by UUID id) for persistent ES256 keypair storage (public JWK + AES-256-GCM encrypted private key) 55 56 - `migrations/V013__identity_wallet_oauth_client.sql` - Seeds identity-wallet OAuth client row (INSERT OR IGNORE): client_id `dev.malpercio.identitywallet`, native app type, DPoP required, custom scheme redirect URI 57 + - `migrations/V014__password_reset_tokens.sql` - Adds `password_reset_tokens` table for `requestPasswordReset`/`resetPassword` flows; token stored as SHA-256 hex hash; 1-hour TTL; `used_at` nullable (status derived: valid = used_at IS NULL AND expires_at > now)
+11
crates/relay/src/db/migrations/V014__password_reset_tokens.sql
··· 1 + CREATE TABLE password_reset_tokens ( 2 + token_hash TEXT NOT NULL, 3 + did TEXT NOT NULL, 4 + expires_at TEXT NOT NULL, 5 + used_at TEXT, 6 + created_at TEXT NOT NULL, 7 + PRIMARY KEY (token_hash), 8 + FOREIGN KEY (did) REFERENCES accounts (did) 9 + ); 10 + 11 + CREATE INDEX idx_password_reset_tokens_did ON password_reset_tokens (did);
+5
crates/relay/src/db/mod.rs
··· 1 1 pub mod accounts; 2 2 pub mod dids; 3 3 pub mod oauth; 4 + pub mod password_reset; 4 5 5 6 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; 6 7 use sqlx::SqlitePool; ··· 83 84 Migration { 84 85 version: 13, 85 86 sql: include_str!("migrations/V013__identity_wallet_oauth_client.sql"), 87 + }, 88 + Migration { 89 + version: 14, 90 + sql: include_str!("migrations/V014__password_reset_tokens.sql"), 86 91 }, 87 92 ]; 88 93
+100
crates/relay/src/db/password_reset.rs
··· 1 + // pattern: Imperative Shell 2 + 3 + use common::{ApiError, ErrorCode}; 4 + use sqlx::{Sqlite, Transaction}; 5 + 6 + pub(crate) struct ResetTokenRow { 7 + pub(crate) did: String, 8 + pub(crate) used_at: Option<String>, 9 + } 10 + 11 + /// Insert a new password reset token into the database with a 1-hour expiry. 12 + /// 13 + /// `token_hash` is the SHA-256 hex digest of the plaintext token (never stored in plaintext). 14 + /// The expiry is always 1 hour from the current DB clock, matching the ATProto spec. 15 + pub(crate) async fn insert_reset_token( 16 + db: &sqlx::SqlitePool, 17 + did: &str, 18 + token_hash: &str, 19 + ) -> Result<(), ApiError> { 20 + sqlx::query( 21 + "INSERT INTO password_reset_tokens \ 22 + (token_hash, did, expires_at, created_at) \ 23 + VALUES (?, ?, datetime('now', '+1 hour'), datetime('now'))", 24 + ) 25 + .bind(token_hash) 26 + .bind(did) 27 + .execute(db) 28 + .await 29 + .map_err(|e| { 30 + tracing::error!(error = %e, "failed to insert password reset token"); 31 + ApiError::new(ErrorCode::InternalError, "failed to create reset token") 32 + })?; 33 + Ok(()) 34 + } 35 + 36 + /// Look up a reset token by its SHA-256 hash within an open transaction. 37 + /// 38 + /// Returns `None` if no row matches. The caller is responsible for checking 39 + /// `expires_at` and `used_at` to determine validity. 40 + pub(crate) async fn get_reset_token( 41 + tx: &mut Transaction<'_, Sqlite>, 42 + token_hash: &str, 43 + ) -> Result<Option<ResetTokenRow>, ApiError> { 44 + let row: Option<(String, Option<String>)> = sqlx::query_as( 45 + "SELECT did, used_at \ 46 + FROM password_reset_tokens \ 47 + WHERE token_hash = ?", 48 + ) 49 + .bind(token_hash) 50 + .fetch_optional(&mut **tx) 51 + .await 52 + .map_err(|e| { 53 + tracing::error!(error = %e, "failed to look up password reset token"); 54 + ApiError::new(ErrorCode::InternalError, "failed to look up reset token") 55 + })?; 56 + 57 + Ok(row.map(|(did, used_at)| ResetTokenRow { did, used_at })) 58 + } 59 + 60 + /// Mark a reset token as used by setting `used_at` to the current time. 61 + pub(crate) async fn mark_reset_token_used( 62 + tx: &mut Transaction<'_, Sqlite>, 63 + token_hash: &str, 64 + ) -> Result<(), ApiError> { 65 + sqlx::query( 66 + "UPDATE password_reset_tokens \ 67 + SET used_at = datetime('now') \ 68 + WHERE token_hash = ?", 69 + ) 70 + .bind(token_hash) 71 + .execute(&mut **tx) 72 + .await 73 + .map_err(|e| { 74 + tracing::error!(error = %e, "failed to mark reset token as used"); 75 + ApiError::new(ErrorCode::InternalError, "failed to consume reset token") 76 + })?; 77 + Ok(()) 78 + } 79 + 80 + /// Update the password hash for an account within an open transaction. 81 + pub(crate) async fn update_password_hash( 82 + tx: &mut Transaction<'_, Sqlite>, 83 + did: &str, 84 + password_hash: &str, 85 + ) -> Result<(), ApiError> { 86 + sqlx::query( 87 + "UPDATE accounts \ 88 + SET password_hash = ?, updated_at = datetime('now') \ 89 + WHERE did = ?", 90 + ) 91 + .bind(password_hash) 92 + .bind(did) 93 + .execute(&mut **tx) 94 + .await 95 + .map_err(|e| { 96 + tracing::error!(did = %did, error = %e, "failed to update password hash"); 97 + ApiError::new(ErrorCode::InternalError, "failed to update password") 98 + })?; 99 + Ok(()) 100 + }
+2
crates/relay/src/routes/mod.rs
··· 23 23 pub mod provisioning_session; 24 24 pub mod refresh_session; 25 25 pub mod register_device; 26 + pub mod request_password_reset; 27 + pub mod reset_password; 26 28 pub mod resolve_handle; 27 29 28 30 mod code_gen;
+197
crates/relay/src/routes/request_password_reset.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON body {email}, DB pool 4 + // Processes: email lookup → token generation → DB insert → log token (email stub) 5 + // Returns: 200 always (prevents email enumeration) 6 + // 7 + // Implements: POST /xrpc/com.atproto.server.requestPasswordReset 8 + 9 + use axum::{extract::State, http::StatusCode}; 10 + use serde::Deserialize; 11 + 12 + use crate::app::AppState; 13 + use crate::db::accounts::resolve_by_email; 14 + use crate::db::password_reset::insert_reset_token; 15 + use crate::routes::token::generate_token; 16 + 17 + #[derive(Deserialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct RequestPasswordResetRequest { 20 + email: String, 21 + } 22 + 23 + /// POST /xrpc/com.atproto.server.requestPasswordReset 24 + /// 25 + /// Generates a short-lived (1-hour) single-use reset token for the given email address. 26 + /// Always returns 200 regardless of whether the email exists, to prevent account enumeration. 27 + /// Email delivery is stubbed — the plaintext token is logged via `tracing::info!`. 28 + pub async fn request_password_reset( 29 + State(state): State<AppState>, 30 + axum::Json(payload): axum::Json<RequestPasswordResetRequest>, 31 + ) -> StatusCode { 32 + // --- Look up account by email --- 33 + // Silently return 200 on any failure to prevent enumeration. 34 + let account = match resolve_by_email(&state.db, &payload.email).await { 35 + Ok(Some(account)) => account, 36 + Ok(None) => return StatusCode::OK, 37 + Err(_) => return StatusCode::OK, 38 + }; 39 + 40 + // --- Generate reset token --- 41 + let token = generate_token(); 42 + 43 + // --- Persist token in DB --- 44 + if let Err(e) = insert_reset_token(&state.db, &account.did, &token.hash).await { 45 + tracing::error!(error = %e, "failed to store password reset token; returning 200 to prevent enumeration"); 46 + return StatusCode::OK; 47 + } 48 + 49 + // --- Stub: log token (replace with email delivery in a future wave) --- 50 + tracing::info!( 51 + did = %account.did, 52 + reset_token = %token.plaintext, 53 + "password reset token generated (email delivery not yet implemented)" 54 + ); 55 + 56 + StatusCode::OK 57 + } 58 + 59 + // ── Tests ──────────────────────────────────────────────────────────────────── 60 + 61 + #[cfg(test)] 62 + mod tests { 63 + use axum::{ 64 + body::Body, 65 + http::{Request, StatusCode}, 66 + }; 67 + use tower::ServiceExt; 68 + 69 + use crate::app::{app, test_state}; 70 + use crate::routes::test_utils::insert_account_with_password; 71 + 72 + fn post_request_password_reset(email: &str) -> Request<Body> { 73 + Request::builder() 74 + .method("POST") 75 + .uri("/xrpc/com.atproto.server.requestPasswordReset") 76 + .header("Content-Type", "application/json") 77 + .body(Body::from(format!(r#"{{"email":"{email}"}}"#))) 78 + .unwrap() 79 + } 80 + 81 + #[tokio::test] 82 + async fn returns_200_for_known_email() { 83 + let state = test_state().await; 84 + insert_account_with_password( 85 + &state.db, 86 + "did:plc:reset1", 87 + "reset1.test.example.com", 88 + "reset1@example.com", 89 + "hunter2", 90 + ) 91 + .await; 92 + 93 + let response = app(state) 94 + .oneshot(post_request_password_reset("reset1@example.com")) 95 + .await 96 + .unwrap(); 97 + 98 + assert_eq!(response.status(), StatusCode::OK); 99 + } 100 + 101 + #[tokio::test] 102 + async fn returns_200_for_unknown_email() { 103 + let response = app(test_state().await) 104 + .oneshot(post_request_password_reset("nobody@example.com")) 105 + .await 106 + .unwrap(); 107 + 108 + assert_eq!(response.status(), StatusCode::OK); 109 + } 110 + 111 + #[tokio::test] 112 + async fn known_email_inserts_token_in_db() { 113 + let state = test_state().await; 114 + insert_account_with_password( 115 + &state.db, 116 + "did:plc:tokencheck", 117 + "tokencheck.test.example.com", 118 + "tokencheck@example.com", 119 + "pass", 120 + ) 121 + .await; 122 + 123 + let db = state.db.clone(); 124 + app(state) 125 + .oneshot(post_request_password_reset("tokencheck@example.com")) 126 + .await 127 + .unwrap(); 128 + 129 + let count: i64 = sqlx::query_scalar( 130 + "SELECT COUNT(*) FROM password_reset_tokens WHERE did = 'did:plc:tokencheck'", 131 + ) 132 + .fetch_one(&db) 133 + .await 134 + .unwrap(); 135 + assert_eq!(count, 1, "one reset token should be inserted"); 136 + } 137 + 138 + #[tokio::test] 139 + async fn unknown_email_does_not_insert_token() { 140 + let state = test_state().await; 141 + let db = state.db.clone(); 142 + 143 + app(state) 144 + .oneshot(post_request_password_reset("ghost@example.com")) 145 + .await 146 + .unwrap(); 147 + 148 + let count: i64 = 149 + sqlx::query_scalar("SELECT COUNT(*) FROM password_reset_tokens") 150 + .fetch_one(&db) 151 + .await 152 + .unwrap(); 153 + assert_eq!(count, 0, "no token should be inserted for unknown email"); 154 + } 155 + 156 + #[tokio::test] 157 + async fn response_body_is_empty() { 158 + let response = app(test_state().await) 159 + .oneshot(post_request_password_reset("any@example.com")) 160 + .await 161 + .unwrap(); 162 + 163 + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 164 + .await 165 + .unwrap(); 166 + assert!(bytes.is_empty(), "response body must be empty"); 167 + } 168 + 169 + #[tokio::test] 170 + async fn token_has_1_hour_expiry() { 171 + let state = test_state().await; 172 + insert_account_with_password( 173 + &state.db, 174 + "did:plc:expiry", 175 + "expiry.test.example.com", 176 + "expiry@example.com", 177 + "pass", 178 + ) 179 + .await; 180 + 181 + let db = state.db.clone(); 182 + app(state) 183 + .oneshot(post_request_password_reset("expiry@example.com")) 184 + .await 185 + .unwrap(); 186 + 187 + // expires_at should be approximately 1 hour in the future (within 5 seconds of drift). 188 + let diff: i64 = sqlx::query_scalar( 189 + "SELECT ABS(strftime('%s', expires_at) - strftime('%s', datetime('now', '+1 hour'))) \ 190 + FROM password_reset_tokens WHERE did = 'did:plc:expiry'", 191 + ) 192 + .fetch_one(&db) 193 + .await 194 + .unwrap(); 195 + assert!(diff < 5, "expiry should be ~1 hour from now, got {diff}s drift"); 196 + } 197 + }
+345
crates/relay/src/routes/reset_password.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON body {token, password}, DB pool 4 + // Processes: token hash → lookup → expiry/used check → argon2id hash → 5 + // atomic tx (mark used + update password_hash) 6 + // Returns: 200 on success; 401 InvalidToken if not found; 400 ExpiredToken if expired/used 7 + // 8 + // Implements: POST /xrpc/com.atproto.server.resetPassword 9 + 10 + use axum::{extract::State, http::StatusCode}; 11 + use serde::Deserialize; 12 + 13 + use common::{ApiError, ErrorCode}; 14 + 15 + use crate::app::AppState; 16 + use crate::auth::password::hash_password; 17 + use crate::db::password_reset::{get_reset_token, mark_reset_token_used, update_password_hash}; 18 + use crate::routes::token::hash_bearer_token; 19 + 20 + #[derive(Deserialize)] 21 + #[serde(rename_all = "camelCase")] 22 + pub struct ResetPasswordRequest { 23 + token: String, 24 + password: String, 25 + } 26 + 27 + /// POST /xrpc/com.atproto.server.resetPassword 28 + /// 29 + /// Validates a single-use reset token, hashes the new password with argon2id, 30 + /// and atomically marks the token used and updates `accounts.password_hash`. 31 + pub async fn reset_password( 32 + State(state): State<AppState>, 33 + axum::Json(payload): axum::Json<ResetPasswordRequest>, 34 + ) -> Result<StatusCode, ApiError> { 35 + // --- Hash the incoming plaintext token to look it up in the DB --- 36 + let token_hash = hash_bearer_token(&payload.token) 37 + .map_err(|_| ApiError::new(ErrorCode::InvalidToken, "invalid reset token"))?; 38 + 39 + // --- Begin atomic transaction --- 40 + // The lookup and the two writes (mark used + update password) must be atomic: 41 + // a race on the same token would otherwise let two requests both pass the 42 + // validity check. SQLite's single-connection pool makes this safe without 43 + // explicit row locks, but using a transaction is still the correct model. 44 + let mut tx = state.db.begin().await.map_err(|e| { 45 + tracing::error!(error = %e, "failed to begin reset_password transaction"); 46 + ApiError::new(ErrorCode::InternalError, "failed to reset password") 47 + })?; 48 + 49 + // --- Look up token --- 50 + let row = get_reset_token(&mut tx, &token_hash).await?.ok_or_else(|| { 51 + ApiError::new(ErrorCode::InvalidToken, "invalid or unknown reset token") 52 + })?; 53 + 54 + // --- Validate: not expired, not already used --- 55 + // Check used_at first — a consumed token is non-recoverable regardless of expiry. 56 + if row.used_at.is_some() { 57 + return Err(ApiError::new( 58 + ErrorCode::ExpiredToken, 59 + "this reset token has already been used", 60 + )); 61 + } 62 + 63 + let is_expired: bool = sqlx::query_scalar( 64 + "SELECT expires_at <= datetime('now') FROM password_reset_tokens WHERE token_hash = ?", 65 + ) 66 + .bind(&token_hash) 67 + .fetch_one(&mut *tx) 68 + .await 69 + .map_err(|e| { 70 + tracing::error!(error = %e, "failed to check token expiry"); 71 + ApiError::new(ErrorCode::InternalError, "failed to validate reset token") 72 + })?; 73 + 74 + if is_expired { 75 + return Err(ApiError::new( 76 + ErrorCode::ExpiredToken, 77 + "this reset token has expired", 78 + )); 79 + } 80 + 81 + // --- Hash new password --- 82 + let new_hash = hash_password(&payload.password)?; 83 + 84 + // --- Atomically: mark token used + update password --- 85 + mark_reset_token_used(&mut tx, &token_hash).await?; 86 + update_password_hash(&mut tx, &row.did, &new_hash).await?; 87 + 88 + tx.commit().await.map_err(|e| { 89 + tracing::error!(error = %e, "failed to commit reset_password transaction"); 90 + ApiError::new(ErrorCode::InternalError, "failed to reset password") 91 + })?; 92 + 93 + Ok(StatusCode::OK) 94 + } 95 + 96 + // ── Tests ──────────────────────────────────────────────────────────────────── 97 + 98 + #[cfg(test)] 99 + mod tests { 100 + use axum::{ 101 + body::Body, 102 + http::{Request, StatusCode}, 103 + }; 104 + use tower::ServiceExt; 105 + 106 + use crate::app::{app, test_state}; 107 + use crate::routes::test_utils::{body_json, insert_account_with_password}; 108 + use crate::routes::token::generate_token; 109 + 110 + fn post_reset_password(token: &str, password: &str) -> Request<Body> { 111 + Request::builder() 112 + .method("POST") 113 + .uri("/xrpc/com.atproto.server.resetPassword") 114 + .header("Content-Type", "application/json") 115 + .body(Body::from(format!( 116 + r#"{{"token":"{token}","password":"{password}"}}"# 117 + ))) 118 + .unwrap() 119 + } 120 + 121 + /// Seed a valid (non-expired, unused) reset token in the DB. Returns plaintext token. 122 + async fn seed_reset_token(db: &sqlx::SqlitePool, did: &str) -> String { 123 + let token = generate_token(); 124 + sqlx::query( 125 + "INSERT INTO password_reset_tokens \ 126 + (token_hash, did, expires_at, created_at) \ 127 + VALUES (?, ?, datetime('now', '+1 hour'), datetime('now'))", 128 + ) 129 + .bind(&token.hash) 130 + .bind(did) 131 + .execute(db) 132 + .await 133 + .unwrap(); 134 + token.plaintext 135 + } 136 + 137 + /// Seed an expired reset token. Returns plaintext token. 138 + async fn seed_expired_token(db: &sqlx::SqlitePool, did: &str) -> String { 139 + let token = generate_token(); 140 + sqlx::query( 141 + "INSERT INTO password_reset_tokens \ 142 + (token_hash, did, expires_at, created_at) \ 143 + VALUES (?, ?, datetime('now', '-1 hour'), datetime('now'))", 144 + ) 145 + .bind(&token.hash) 146 + .bind(did) 147 + .execute(db) 148 + .await 149 + .unwrap(); 150 + token.plaintext 151 + } 152 + 153 + // ── Happy path ──────────────────────────────────────────────────────────── 154 + 155 + #[tokio::test] 156 + async fn valid_token_returns_200() { 157 + let state = test_state().await; 158 + insert_account_with_password( 159 + &state.db, 160 + "did:plc:rp1", 161 + "rp1.test.example.com", 162 + "rp1@example.com", 163 + "oldpass", 164 + ) 165 + .await; 166 + let token = seed_reset_token(&state.db, "did:plc:rp1").await; 167 + 168 + let response = app(state) 169 + .oneshot(post_reset_password(&token, "newpass123")) 170 + .await 171 + .unwrap(); 172 + 173 + assert_eq!(response.status(), StatusCode::OK); 174 + } 175 + 176 + #[tokio::test] 177 + async fn valid_token_updates_password_hash() { 178 + let state = test_state().await; 179 + insert_account_with_password( 180 + &state.db, 181 + "did:plc:rp2", 182 + "rp2.test.example.com", 183 + "rp2@example.com", 184 + "oldpass", 185 + ) 186 + .await; 187 + let token = seed_reset_token(&state.db, "did:plc:rp2").await; 188 + 189 + let db = state.db.clone(); 190 + app(state) 191 + .oneshot(post_reset_password(&token, "brandnewpass")) 192 + .await 193 + .unwrap(); 194 + 195 + let hash: Option<String> = 196 + sqlx::query_scalar("SELECT password_hash FROM accounts WHERE did = 'did:plc:rp2'") 197 + .fetch_one(&db) 198 + .await 199 + .unwrap(); 200 + let hash = hash.expect("password_hash must not be null after reset"); 201 + // Verify the new hash authenticates with the new password. 202 + use crate::auth::password::{verify_password, VerifyResult}; 203 + assert!( 204 + matches!(verify_password(&hash, "brandnewpass"), VerifyResult::Ok), 205 + "new password_hash must verify with the submitted password" 206 + ); 207 + // Verify old password no longer works. 208 + assert!( 209 + !matches!(verify_password(&hash, "oldpass"), VerifyResult::Ok), 210 + "old password must not verify against the new hash" 211 + ); 212 + } 213 + 214 + #[tokio::test] 215 + async fn valid_token_marks_token_as_used() { 216 + let state = test_state().await; 217 + insert_account_with_password( 218 + &state.db, 219 + "did:plc:rp3", 220 + "rp3.test.example.com", 221 + "rp3@example.com", 222 + "pass", 223 + ) 224 + .await; 225 + let token = seed_reset_token(&state.db, "did:plc:rp3").await; 226 + let db = state.db.clone(); 227 + 228 + app(state) 229 + .oneshot(post_reset_password(&token, "newpass")) 230 + .await 231 + .unwrap(); 232 + 233 + let used_at: Option<String> = sqlx::query_scalar( 234 + "SELECT used_at FROM password_reset_tokens WHERE did = 'did:plc:rp3'", 235 + ) 236 + .fetch_one(&db) 237 + .await 238 + .unwrap(); 239 + assert!(used_at.is_some(), "used_at must be set after successful reset"); 240 + } 241 + 242 + #[tokio::test] 243 + async fn response_body_is_empty_on_success() { 244 + let state = test_state().await; 245 + insert_account_with_password( 246 + &state.db, 247 + "did:plc:rp4", 248 + "rp4.test.example.com", 249 + "rp4@example.com", 250 + "pass", 251 + ) 252 + .await; 253 + let token = seed_reset_token(&state.db, "did:plc:rp4").await; 254 + 255 + let response = app(state) 256 + .oneshot(post_reset_password(&token, "newpass")) 257 + .await 258 + .unwrap(); 259 + 260 + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 261 + .await 262 + .unwrap(); 263 + assert!(bytes.is_empty(), "response body must be empty on 200"); 264 + } 265 + 266 + // ── Error paths ─────────────────────────────────────────────────────────── 267 + 268 + #[tokio::test] 269 + async fn unknown_token_returns_401() { 270 + let token = generate_token(); 271 + let response = app(test_state().await) 272 + .oneshot(post_reset_password(&token.plaintext, "newpass")) 273 + .await 274 + .unwrap(); 275 + 276 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 277 + let json = body_json(response).await; 278 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 279 + } 280 + 281 + #[tokio::test] 282 + async fn expired_token_returns_400() { 283 + let state = test_state().await; 284 + insert_account_with_password( 285 + &state.db, 286 + "did:plc:expired", 287 + "expired.test.example.com", 288 + "expired@example.com", 289 + "pass", 290 + ) 291 + .await; 292 + let token = seed_expired_token(&state.db, "did:plc:expired").await; 293 + 294 + let response = app(state) 295 + .oneshot(post_reset_password(&token, "newpass")) 296 + .await 297 + .unwrap(); 298 + 299 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 300 + let json = body_json(response).await; 301 + assert_eq!(json["error"]["code"], "ExpiredToken"); 302 + } 303 + 304 + #[tokio::test] 305 + async fn used_token_returns_400() { 306 + let state = test_state().await; 307 + insert_account_with_password( 308 + &state.db, 309 + "did:plc:used", 310 + "used.test.example.com", 311 + "used@example.com", 312 + "pass", 313 + ) 314 + .await; 315 + let token = seed_reset_token(&state.db, "did:plc:used").await; 316 + 317 + // First use — should succeed. 318 + app(state.clone()) 319 + .oneshot(post_reset_password(&token, "newpass1")) 320 + .await 321 + .unwrap(); 322 + 323 + // Second use — should return 400 ExpiredToken. 324 + let response = app(state) 325 + .oneshot(post_reset_password(&token, "newpass2")) 326 + .await 327 + .unwrap(); 328 + 329 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 330 + let json = body_json(response).await; 331 + assert_eq!(json["error"]["code"], "ExpiredToken"); 332 + } 333 + 334 + #[tokio::test] 335 + async fn malformed_token_returns_401() { 336 + let response = app(test_state().await) 337 + .oneshot(post_reset_password("not-valid-base64url!!!", "newpass")) 338 + .await 339 + .unwrap(); 340 + 341 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 342 + let json = body_json(response).await; 343 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 344 + } 345 + }