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 POST /v1/accounts/sessions (provisioning login)

Adds email+password login for the provisioning API, issuing a 1-year
opaque bearer session token. Corrects the original MM-85 design (which
described device_token auth) now that all mobile accounts have a
password set during the DID ceremony.

- New route: POST /v1/accounts/sessions → {session_token, did}
- New DB helper: resolve_by_email in db/accounts.rs (same AccountRow)
- Reuses: verify_password, is_rate_limited/record_failure/clear_failures,
generate_token — no new auth primitives
- 10 tests: happy path, DB persistence, require_session compatibility,
wrong password, unknown email, null password_hash, deactivated account,
user enumeration resistance, rate limiting, counter clear on success
- Bruno: create_provisioning_session.bru (seq 20)

authored by

Malpercio and committed by
Tangled
5cba6aa2 c28abc66

+563
+18
bruno/create_provisioning_session.bru
··· 1 + meta { 2 + name: Create Provisioning Session 3 + type: http 4 + seq: 20 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/accounts/sessions 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "email": "user@example.com", 16 + "password": "yourpassword" 17 + } 18 + }
+2
crates/relay/src/app.rs
··· 32 32 use crate::routes::oauth_par::post_par; 33 33 use crate::routes::oauth_server_metadata::oauth_server_metadata; 34 34 use crate::routes::oauth_token::post_token; 35 + use crate::routes::provisioning_session::create_provisioning_session; 35 36 use crate::routes::register_device::register_device; 36 37 use crate::routes::resolve_handle::resolve_handle_handler; 37 38 use crate::well_known::WellKnownResolver; ··· 160 161 .route("/v1/accounts", post(create_account)) 161 162 .route("/v1/accounts/claim-codes", post(claim_codes)) 162 163 .route("/v1/accounts/mobile", post(create_mobile_account)) 164 + .route("/v1/accounts/sessions", post(create_provisioning_session)) 163 165 .route("/v1/devices", post(register_device)) 164 166 .route("/v1/dids", post(create_did_handler)) 165 167 .route("/v1/handles", post(create_handle_handler))
+31
crates/relay/src/db/accounts.rs
··· 63 63 )) 64 64 } 65 65 66 + /// Resolve an email address to an active (non-deactivated) account. 67 + /// 68 + /// Used by the provisioning session login endpoint (`POST /v1/accounts/sessions`). 69 + /// Returns `None` when not found or deactivated; `Err` only on DB errors. 70 + pub(crate) async fn resolve_by_email( 71 + db: &sqlx::SqlitePool, 72 + email: &str, 73 + ) -> Result<Option<AccountRow>, ApiError> { 74 + let row: Option<(String, Option<String>, Option<String>)> = sqlx::query_as( 75 + "SELECT a.did, a.password_hash, h.handle \ 76 + FROM accounts a \ 77 + LEFT JOIN handles h ON h.did = a.did \ 78 + WHERE a.email = ? AND a.deactivated_at IS NULL \ 79 + LIMIT 1", 80 + ) 81 + .bind(email) 82 + .fetch_optional(db) 83 + .await 84 + .map_err(|e| { 85 + tracing::error!(error = %e, "DB error resolving email"); 86 + ApiError::new(ErrorCode::InternalError, "failed to resolve identifier") 87 + })?; 88 + 89 + Ok(row.map(|(did, password_hash, handle)| AccountRow { 90 + did, 91 + email: email.to_string(), 92 + password_hash, 93 + handle, 94 + })) 95 + } 96 + 66 97 /// Resolve a handle or DID to an active (non-deactivated) account. 67 98 /// 68 99 /// Returns `None` when not found; `Err` only on DB errors.
+1
crates/relay/src/routes/mod.rs
··· 16 16 pub mod oauth_server_metadata; 17 17 pub(super) mod oauth_templates; 18 18 pub mod oauth_token; 19 + pub mod provisioning_session; 19 20 pub mod register_device; 20 21 pub mod resolve_handle; 21 22
+511
crates/relay/src/routes/provisioning_session.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON body {email, password}, DB pool, rate-limit state 4 + // Processes: rate limit gate → email resolution → password verification → 5 + // session token generation → sessions DB insert 6 + // Returns: JSON {session_token, did} on success; ApiError on failure 7 + // 8 + // Implements: POST /v1/accounts/sessions 9 + 10 + use axum::{extract::State, http::StatusCode, response::Json}; 11 + use serde::{Deserialize, Serialize}; 12 + use uuid::Uuid; 13 + 14 + use common::{ApiError, ErrorCode}; 15 + 16 + use crate::app::AppState; 17 + use crate::auth::password::{verify_password, VerifyResult}; 18 + use crate::auth::rate_limit::{clear_failures, is_rate_limited, record_failure}; 19 + use crate::db::accounts::resolve_by_email; 20 + use crate::routes::token::generate_token; 21 + 22 + // ── Request / Response types ───────────────────────────────────────────────── 23 + 24 + #[derive(Deserialize)] 25 + #[serde(rename_all = "camelCase")] 26 + pub struct CreateProvisioningSessionRequest { 27 + email: String, 28 + password: String, 29 + } 30 + 31 + #[derive(Serialize)] 32 + #[serde(rename_all = "camelCase")] 33 + pub struct CreateProvisioningSessionResponse { 34 + session_token: String, 35 + did: String, 36 + } 37 + 38 + // ── Handler ────────────────────────────────────────────────────────────────── 39 + 40 + /// POST /v1/accounts/sessions 41 + /// 42 + /// Email + password login for the provisioning API. Issues a 1-year opaque bearer 43 + /// token stored in the `sessions` table. Used when the session token has expired or 44 + /// been lost (e.g., app reinstall). The returned `session_token` works with 45 + /// `require_session`-protected provisioning endpoints. 46 + pub async fn create_provisioning_session( 47 + State(state): State<AppState>, 48 + Json(payload): Json<CreateProvisioningSessionRequest>, 49 + ) -> Result<(StatusCode, Json<CreateProvisioningSessionResponse>), ApiError> { 50 + // --- Rate limit gate --- 51 + // Check before any DB work to shed load on targeted accounts. 52 + { 53 + let mut attempts = state.failed_login_attempts.lock().map_err(|_| { 54 + tracing::error!("failed_login_attempts mutex is poisoned"); 55 + ApiError::new(ErrorCode::InternalError, "internal error") 56 + })?; 57 + if is_rate_limited(&mut attempts, &payload.email) { 58 + return Err(ApiError::new( 59 + ErrorCode::RateLimited, 60 + "too many failed login attempts, please try again later", 61 + )); 62 + } 63 + } 64 + 65 + // --- Resolve email and verify password --- 66 + // Both "account not found" and "wrong password" surface as the same error to prevent 67 + // user enumeration via distinguishable error messages. 68 + let account_opt = resolve_by_email(&state.db, &payload.email).await?; 69 + 70 + let account = match account_opt { 71 + Some(row) => { 72 + let result = match row.password_hash.as_deref() { 73 + // Accounts without a password_hash cannot use provisioning session login. 74 + None | Some("") => VerifyResult::WrongPassword, 75 + Some(h) => verify_password(h, &payload.password), 76 + }; 77 + match result { 78 + VerifyResult::Ok => {} 79 + VerifyResult::WrongPassword => { 80 + let mut attempts = state.failed_login_attempts.lock().map_err(|_| { 81 + tracing::error!("failed_login_attempts mutex is poisoned"); 82 + ApiError::new(ErrorCode::InternalError, "internal error") 83 + })?; 84 + record_failure(&mut attempts, &payload.email); 85 + return Err(ApiError::new( 86 + ErrorCode::AuthenticationRequired, 87 + "invalid email or password", 88 + )); 89 + } 90 + VerifyResult::CorruptHash => { 91 + tracing::error!( 92 + "stored password_hash is not a valid PHC string; possible DB corruption" 93 + ); 94 + return Err(ApiError::new(ErrorCode::InternalError, "internal error")); 95 + } 96 + } 97 + row 98 + } 99 + None => { 100 + let mut attempts = state.failed_login_attempts.lock().map_err(|_| { 101 + tracing::error!("failed_login_attempts mutex is poisoned"); 102 + ApiError::new(ErrorCode::InternalError, "internal error") 103 + })?; 104 + record_failure(&mut attempts, &payload.email); 105 + return Err(ApiError::new( 106 + ErrorCode::AuthenticationRequired, 107 + "invalid email or password", 108 + )); 109 + } 110 + }; 111 + 112 + // --- Issue opaque bearer session token --- 113 + let session_token = generate_token(); 114 + let session_id = Uuid::new_v4().to_string(); 115 + 116 + sqlx::query( 117 + "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ 118 + VALUES (?, ?, NULL, ?, datetime('now'), datetime('now', '+1 year'))", 119 + ) 120 + .bind(&session_id) 121 + .bind(&account.did) 122 + .bind(&session_token.hash) 123 + .execute(&state.db) 124 + .await 125 + .map_err(|e| { 126 + tracing::error!(error = %e, "failed to insert provisioning session"); 127 + ApiError::new(ErrorCode::InternalError, "failed to create session") 128 + })?; 129 + 130 + // Clear failure history only after the session is fully committed. 131 + { 132 + let mut attempts = state.failed_login_attempts.lock().map_err(|_| { 133 + tracing::error!("failed_login_attempts mutex is poisoned"); 134 + ApiError::new(ErrorCode::InternalError, "internal error") 135 + })?; 136 + clear_failures(&mut attempts, &payload.email); 137 + } 138 + 139 + Ok(( 140 + StatusCode::OK, 141 + Json(CreateProvisioningSessionResponse { 142 + session_token: session_token.plaintext, 143 + did: account.did, 144 + }), 145 + )) 146 + } 147 + 148 + // ── Tests ──────────────────────────────────────────────────────────────────── 149 + 150 + #[cfg(test)] 151 + mod tests { 152 + use argon2::{ 153 + password_hash::{rand_core::OsRng, SaltString}, 154 + Argon2, PasswordHasher, 155 + }; 156 + use axum::{ 157 + body::Body, 158 + http::{Request, StatusCode}, 159 + }; 160 + use tower::ServiceExt; 161 + 162 + use crate::app::{app, test_state}; 163 + use crate::auth::rate_limit::RATE_LIMIT_MAX_FAILURES; 164 + 165 + // ── Helpers ─────────────────────────────────────────────────────────────── 166 + 167 + fn post_provisioning_session(email: &str, password: &str) -> Request<Body> { 168 + Request::builder() 169 + .method("POST") 170 + .uri("/v1/accounts/sessions") 171 + .header("Content-Type", "application/json") 172 + .body(Body::from(format!( 173 + r#"{{"email":"{email}","password":"{password}"}}"# 174 + ))) 175 + .unwrap() 176 + } 177 + 178 + async fn insert_account_with_password( 179 + db: &sqlx::SqlitePool, 180 + did: &str, 181 + handle: &str, 182 + email: &str, 183 + password: &str, 184 + ) { 185 + let salt = SaltString::generate(&mut OsRng); 186 + let hash = Argon2::default() 187 + .hash_password(password.as_bytes(), &salt) 188 + .unwrap() 189 + .to_string(); 190 + 191 + sqlx::query( 192 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 193 + VALUES (?, ?, ?, datetime('now'), datetime('now'))", 194 + ) 195 + .bind(did) 196 + .bind(email) 197 + .bind(&hash) 198 + .execute(db) 199 + .await 200 + .unwrap(); 201 + 202 + sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 203 + .bind(handle) 204 + .bind(did) 205 + .execute(db) 206 + .await 207 + .unwrap(); 208 + } 209 + 210 + async fn body_json(response: axum::response::Response) -> serde_json::Value { 211 + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 212 + .await 213 + .unwrap(); 214 + serde_json::from_slice(&bytes).unwrap() 215 + } 216 + 217 + // ── Happy path ──────────────────────────────────────────────────────────── 218 + 219 + #[tokio::test] 220 + async fn valid_email_and_password_returns_200_with_session_token() { 221 + let state = test_state().await; 222 + insert_account_with_password( 223 + &state.db, 224 + "did:plc:alice", 225 + "alice.test.example.com", 226 + "alice@example.com", 227 + "hunter2", 228 + ) 229 + .await; 230 + 231 + let response = app(state) 232 + .oneshot(post_provisioning_session("alice@example.com", "hunter2")) 233 + .await 234 + .unwrap(); 235 + 236 + assert_eq!(response.status(), StatusCode::OK); 237 + let json = body_json(response).await; 238 + assert!( 239 + json["sessionToken"].as_str().is_some(), 240 + "sessionToken required" 241 + ); 242 + assert_eq!(json["did"], "did:plc:alice"); 243 + } 244 + 245 + #[tokio::test] 246 + async fn session_token_is_persisted_in_db() { 247 + let state = test_state().await; 248 + insert_account_with_password( 249 + &state.db, 250 + "did:plc:persist", 251 + "persist.test.example.com", 252 + "persist@example.com", 253 + "testpass", 254 + ) 255 + .await; 256 + 257 + let db = state.db.clone(); 258 + let response = app(state) 259 + .oneshot(post_provisioning_session("persist@example.com", "testpass")) 260 + .await 261 + .unwrap(); 262 + 263 + assert_eq!(response.status(), StatusCode::OK); 264 + 265 + let session_count: i64 = 266 + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE did = 'did:plc:persist'") 267 + .fetch_one(&db) 268 + .await 269 + .unwrap(); 270 + assert_eq!(session_count, 1, "one session row expected"); 271 + } 272 + 273 + #[tokio::test] 274 + async fn session_token_hash_is_found_by_require_session_query() { 275 + // Verify that the issued token can be looked up by the same query 276 + // `require_session` uses: SELECT did FROM sessions WHERE token_hash = ? 277 + // AND expires_at > datetime('now'). 278 + let state = test_state().await; 279 + insert_account_with_password( 280 + &state.db, 281 + "did:plc:authcheck", 282 + "authcheck.test.example.com", 283 + "authcheck@example.com", 284 + "authpass", 285 + ) 286 + .await; 287 + 288 + let response = app(state.clone()) 289 + .oneshot(post_provisioning_session( 290 + "authcheck@example.com", 291 + "authpass", 292 + )) 293 + .await 294 + .unwrap(); 295 + 296 + let json = body_json(response).await; 297 + let token = json["sessionToken"].as_str().unwrap(); 298 + 299 + // Hash the token (same as require_session does internally). 300 + let hash = crate::routes::token::hash_bearer_token(token).unwrap(); 301 + 302 + let did: Option<String> = sqlx::query_scalar( 303 + "SELECT did FROM sessions WHERE token_hash = ? AND expires_at > datetime('now')", 304 + ) 305 + .bind(&hash) 306 + .fetch_optional(&state.db) 307 + .await 308 + .unwrap(); 309 + 310 + assert_eq!(did.as_deref(), Some("did:plc:authcheck"), 311 + "require_session query must find the issued token"); 312 + } 313 + 314 + // ── Auth failures ───────────────────────────────────────────────────────── 315 + 316 + #[tokio::test] 317 + async fn wrong_password_returns_401() { 318 + let state = test_state().await; 319 + insert_account_with_password( 320 + &state.db, 321 + "did:plc:charlie", 322 + "charlie.test.example.com", 323 + "charlie@example.com", 324 + "correcthorsebatterystaple", 325 + ) 326 + .await; 327 + 328 + let response = app(state) 329 + .oneshot(post_provisioning_session( 330 + "charlie@example.com", 331 + "wrongpassword", 332 + )) 333 + .await 334 + .unwrap(); 335 + 336 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 337 + let json = body_json(response).await; 338 + assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 339 + } 340 + 341 + #[tokio::test] 342 + async fn unknown_email_returns_401() { 343 + let response = app(test_state().await) 344 + .oneshot(post_provisioning_session("nobody@example.com", "password")) 345 + .await 346 + .unwrap(); 347 + 348 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 349 + let json = body_json(response).await; 350 + assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 351 + } 352 + 353 + #[tokio::test] 354 + async fn account_without_password_returns_401() { 355 + let state = test_state().await; 356 + sqlx::query( 357 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 358 + VALUES ('did:plc:nopass', 'nopass@example.com', NULL, datetime('now'), datetime('now'))", 359 + ) 360 + .execute(&state.db) 361 + .await 362 + .unwrap(); 363 + 364 + let response = app(state) 365 + .oneshot(post_provisioning_session("nopass@example.com", "anypassword")) 366 + .await 367 + .unwrap(); 368 + 369 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 370 + } 371 + 372 + #[tokio::test] 373 + async fn deactivated_account_returns_401() { 374 + let state = test_state().await; 375 + insert_account_with_password( 376 + &state.db, 377 + "did:plc:deactivated", 378 + "deact.test.example.com", 379 + "deact@example.com", 380 + "password", 381 + ) 382 + .await; 383 + 384 + sqlx::query( 385 + "UPDATE accounts SET deactivated_at = datetime('now') WHERE did = 'did:plc:deactivated'", 386 + ) 387 + .execute(&state.db) 388 + .await 389 + .unwrap(); 390 + 391 + let response = app(state) 392 + .oneshot(post_provisioning_session("deact@example.com", "password")) 393 + .await 394 + .unwrap(); 395 + 396 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 397 + } 398 + 399 + #[tokio::test] 400 + async fn wrong_password_and_unknown_email_return_identical_errors() { 401 + let state = test_state().await; 402 + insert_account_with_password( 403 + &state.db, 404 + "did:plc:enumtest", 405 + "enumtest.test.example.com", 406 + "enumtest@example.com", 407 + "correctpassword", 408 + ) 409 + .await; 410 + 411 + let wrong_pw = app(state.clone()) 412 + .oneshot(post_provisioning_session("enumtest@example.com", "wrongpassword")) 413 + .await 414 + .unwrap(); 415 + let unknown = app(state) 416 + .oneshot(post_provisioning_session("nobody@example.com", "anything")) 417 + .await 418 + .unwrap(); 419 + 420 + assert_eq!(wrong_pw.status(), unknown.status()); 421 + let wrong_pw_json = body_json(wrong_pw).await; 422 + let unknown_json = body_json(unknown).await; 423 + assert_eq!( 424 + wrong_pw_json["error"]["code"], 425 + unknown_json["error"]["code"] 426 + ); 427 + assert_eq!( 428 + wrong_pw_json["error"]["message"], 429 + unknown_json["error"]["message"] 430 + ); 431 + } 432 + 433 + // ── Rate limiting ───────────────────────────────────────────────────────── 434 + 435 + #[tokio::test] 436 + async fn rate_limit_triggers_after_max_failures() { 437 + let state = test_state().await; 438 + 439 + for i in 0..RATE_LIMIT_MAX_FAILURES { 440 + let response = app(state.clone()) 441 + .oneshot(post_provisioning_session( 442 + "did:plc:ratelimited@example.com", 443 + "wrongpassword", 444 + )) 445 + .await 446 + .unwrap(); 447 + assert_eq!( 448 + response.status(), 449 + StatusCode::UNAUTHORIZED, 450 + "attempt {i} should be 401" 451 + ); 452 + } 453 + 454 + let response = app(state) 455 + .oneshot(post_provisioning_session( 456 + "did:plc:ratelimited@example.com", 457 + "wrongpassword", 458 + )) 459 + .await 460 + .unwrap(); 461 + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); 462 + } 463 + 464 + #[tokio::test] 465 + async fn successful_login_clears_rate_limit_counter() { 466 + let state = test_state().await; 467 + insert_account_with_password( 468 + &state.db, 469 + "did:plc:cleartest", 470 + "cleartest.test.example.com", 471 + "cleartest@example.com", 472 + "correctpassword", 473 + ) 474 + .await; 475 + 476 + // N-1 failed attempts (one below the threshold) 477 + for _ in 0..(RATE_LIMIT_MAX_FAILURES - 1) { 478 + app(state.clone()) 479 + .oneshot(post_provisioning_session( 480 + "cleartest@example.com", 481 + "wrongpassword", 482 + )) 483 + .await 484 + .unwrap(); 485 + } 486 + 487 + // Successful login clears the counter 488 + let ok = app(state.clone()) 489 + .oneshot(post_provisioning_session( 490 + "cleartest@example.com", 491 + "correctpassword", 492 + )) 493 + .await 494 + .unwrap(); 495 + assert_eq!(ok.status(), StatusCode::OK); 496 + 497 + // One more failure should be 401, not 429 — counter was reset 498 + let after = app(state) 499 + .oneshot(post_provisioning_session( 500 + "cleartest@example.com", 501 + "wrongpassword", 502 + )) 503 + .await 504 + .unwrap(); 505 + assert_eq!( 506 + after.status(), 507 + StatusCode::UNAUTHORIZED, 508 + "counter must have been cleared by the successful login" 509 + ); 510 + } 511 + }