An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): implement com.atproto.server.createSession

Adds POST /xrpc/com.atproto.server.createSession — the ATProto legacy
password-based auth endpoint required by older clients.

- Resolves identifier (handle or DID) to account, verifies argon2id hash
- Issues HS256 access JWT (2h) and refresh JWT (90d), persists session +
refresh_token rows atomically
- Sliding-window rate limit: 5 failed attempts per identifier per 60s
- Returns AuthenticationRequired for both unknown identifiers and wrong
passwords (prevents user enumeration)
- Blocks mobile accounts (NULL password_hash) from password auth
- 9 tests covering happy path, auth failures, DB persistence, JWT claims,
and rate limiting

+799 -2
+33
Cargo.lock
··· 139 139 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 140 140 141 141 [[package]] 142 + name = "argon2" 143 + version = "0.5.3" 144 + source = "registry+https://github.com/rust-lang/crates.io-index" 145 + checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" 146 + dependencies = [ 147 + "base64ct", 148 + "blake2", 149 + "cpufeatures", 150 + "password-hash", 151 + ] 152 + 153 + [[package]] 142 154 name = "assert-json-diff" 143 155 version = "2.0.2" 144 156 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 348 360 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 349 361 dependencies = [ 350 362 "serde_core", 363 + ] 364 + 365 + [[package]] 366 + name = "blake2" 367 + version = "0.10.6" 368 + source = "registry+https://github.com/rust-lang/crates.io-index" 369 + checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" 370 + dependencies = [ 371 + "digest", 351 372 ] 352 373 353 374 [[package]] ··· 3387 3408 ] 3388 3409 3389 3410 [[package]] 3411 + name = "password-hash" 3412 + version = "0.5.0" 3413 + source = "registry+https://github.com/rust-lang/crates.io-index" 3414 + checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" 3415 + dependencies = [ 3416 + "base64ct", 3417 + "rand_core 0.6.4", 3418 + "subtle", 3419 + ] 3420 + 3421 + [[package]] 3390 3422 name = "pem" 3391 3423 version = "3.0.6" 3392 3424 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4130 4162 version = "0.1.0" 4131 4163 dependencies = [ 4132 4164 "anyhow", 4165 + "argon2", 4133 4166 "axum", 4134 4167 "base64 0.21.7", 4135 4168 "clap",
+3
Cargo.toml
··· 73 73 # JWT (relay auth) 74 74 jsonwebtoken = "9" 75 75 76 + # Password hashing (relay createSession) 77 + argon2 = { version = "0.5", features = ["password-hash"] } 78 + 76 79 # ATProto handle resolution — DNS TXT fallback (relay) 77 80 hickory-resolver = { version = "0.25", features = ["tokio", "system-config"] } 78 81
+22
bruno/create_session.bru
··· 1 + meta { 2 + name: Create Session (createSession) 3 + type: http 4 + seq: 17 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/xrpc/com.atproto.server.createSession 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "identifier": "alice.example.com", 16 + "password": "hunter2" 17 + } 18 + } 19 + 20 + vars:pre-request { 21 + baseUrl: http://localhost:8080 22 + }
+1
crates/relay/Cargo.toml
··· 39 39 hickory-resolver = { workspace = true } 40 40 jsonwebtoken = { workspace = true } 41 41 p256 = { workspace = true } 42 + argon2 = { workspace = true } 42 43 43 44 [dev-dependencies] 44 45 tower = { workspace = true }
+18 -2
crates/relay/src/app.rs
··· 1 - use std::sync::Arc; 1 + use std::collections::{HashMap, VecDeque}; 2 + use std::sync::{Arc, Mutex}; 3 + use std::time::Instant; 2 4 3 5 use axum::{ 4 6 extract::Path, ··· 25 27 use crate::routes::health::health; 26 28 use crate::routes::oauth_authorize::{get_authorization, post_authorization}; 27 29 use crate::routes::oauth_server_metadata::oauth_server_metadata; 30 + use crate::routes::create_session::create_session; 28 31 use crate::routes::oauth_token::post_token; 29 32 use crate::routes::register_device::register_device; 30 33 use crate::routes::resolve_handle::resolve_handle_handler; 31 34 use crate::well_known::WellKnownResolver; 35 + 36 + /// In-memory store for failed login attempts per identifier (for createSession rate limiting). 37 + /// Maps identifier (DID or handle) → timestamps of recent failures. 38 + /// `std::sync::Mutex` is used because the critical section never awaits. 39 + pub type FailedLoginStore = Arc<Mutex<HashMap<String, VecDeque<Instant>>>>; 32 40 33 41 /// Wraps an `axum::http::HeaderMap` as an OTel text-map [`Extractor`] so that 34 42 /// the W3C `traceparent` and `tracestate` headers can be read by the global propagator. ··· 110 118 /// In-memory store for server-issued DPoP nonces. Shared across all token endpoint requests. 111 119 #[allow(dead_code)] 112 120 pub dpop_nonces: DpopNonceStore, 121 + /// In-memory sliding-window store for failed createSession attempts (rate limiting). 122 + /// Shared across all requests via Arc<Mutex<...>>. 123 + pub failed_login_attempts: FailedLoginStore, 113 124 } 114 125 115 126 /// Build the Axum router with middleware and routes. ··· 133 144 get(describe_server), 134 145 ) 135 146 .route( 147 + "/xrpc/com.atproto.server.createSession", 148 + post(create_session), 149 + ) 150 + .route( 136 151 "/xrpc/com.atproto.identity.resolveHandle", 137 152 get(resolve_handle_handler), 138 153 ) ··· 240 255 jwt_secret: [0x42u8; 32], 241 256 oauth_signing_keypair: test_signing_key, 242 257 dpop_nonces, 258 + failed_login_attempts: Arc::new(Mutex::new(HashMap::new())), 243 259 } 244 260 } 245 261 ··· 324 340 let response = app(test_state().await) 325 341 .oneshot( 326 342 Request::builder() 327 - .uri("/xrpc/com.atproto.server.createSession") 343 + .uri("/xrpc/com.atproto.server.getSession") 328 344 .body(Body::empty()) 329 345 .unwrap(), 330 346 )
+1
crates/relay/src/main.rs
··· 150 150 jwt_secret, 151 151 oauth_signing_keypair, 152 152 dpop_nonces: auth::new_nonce_store(), 153 + failed_login_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), 153 154 }; 154 155 155 156 let listener = tokio::net::TcpListener::bind(&addr)
+1
crates/relay/src/routes/auth.rs
··· 209 209 jwt_secret: base.jwt_secret, 210 210 oauth_signing_keypair: base.oauth_signing_keypair, 211 211 dpop_nonces: base.dpop_nonces, 212 + failed_login_attempts: base.failed_login_attempts, 212 213 } 213 214 } 214 215
+715
crates/relay/src/routes/create_session.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON body {identifier, password}, DB pool, jwt_secret, config, rate-limit state 4 + // Processes: rate limit gate → identifier resolution → password verification → 5 + // JWT issuance → session + refresh_token DB insert 6 + // Returns: JSON {accessJwt, refreshJwt, handle, did, email} on success; ApiError on failure 7 + // 8 + // Implements: POST /xrpc/com.atproto.server.createSession 9 + 10 + use std::collections::{HashMap, VecDeque}; 11 + use std::time::{Instant, SystemTime, UNIX_EPOCH, Duration}; 12 + 13 + use argon2::{Argon2, PasswordHash, PasswordVerifier}; 14 + use axum::{extract::State, http::StatusCode, response::Json}; 15 + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; 16 + use serde::{Deserialize, Serialize}; 17 + use uuid::Uuid; 18 + 19 + use common::{ApiError, ErrorCode}; 20 + 21 + use crate::app::AppState; 22 + 23 + const ACCESS_TOKEN_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours 24 + const REFRESH_TOKEN_TTL_SECS: u64 = 90 * 24 * 60 * 60; // 90 days 25 + pub(crate) const RATE_LIMIT_WINDOW_SECS: u64 = 60; 26 + pub(crate) const RATE_LIMIT_MAX_FAILURES: usize = 5; 27 + 28 + // ── Request / Response types ───────────────────────────────────────────────── 29 + 30 + #[derive(Deserialize)] 31 + #[serde(rename_all = "camelCase")] 32 + pub struct CreateSessionRequest { 33 + identifier: String, 34 + password: String, 35 + } 36 + 37 + #[derive(Serialize)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct CreateSessionResponse { 40 + access_jwt: String, 41 + refresh_jwt: String, 42 + handle: String, 43 + did: String, 44 + email: String, 45 + } 46 + 47 + // ── JWT claim structs ──────────────────────────────────────────────────────── 48 + 49 + /// Claims for a legacy HS256 access token (scope: com.atproto.access). 50 + #[derive(Serialize)] 51 + struct LegacyAccessClaims { 52 + scope: &'static str, 53 + sub: String, 54 + /// Audience — server_did when configured, public_url otherwise. 55 + aud: String, 56 + iat: u64, 57 + exp: u64, 58 + } 59 + 60 + /// Claims for a legacy HS256 refresh token (scope: com.atproto.refresh). 61 + #[derive(Serialize)] 62 + struct LegacyRefreshClaims { 63 + scope: &'static str, 64 + sub: String, 65 + aud: String, 66 + /// Unique token ID stored in `refresh_tokens.jti` for refresh-token rotation. 67 + jti: String, 68 + iat: u64, 69 + exp: u64, 70 + } 71 + 72 + // ── Internal account record ────────────────────────────────────────────────── 73 + 74 + struct AccountRow { 75 + did: String, 76 + email: String, 77 + /// Argon2id PHC string. `None` for mobile accounts (password auth not allowed). 78 + password_hash: Option<String>, 79 + /// One associated handle (if any). Empty string returned in the response when absent. 80 + handle: Option<String>, 81 + } 82 + 83 + // ── Handler ────────────────────────────────────────────────────────────────── 84 + 85 + /// POST /xrpc/com.atproto.server.createSession 86 + /// 87 + /// Password-based authentication (ATProto legacy session flow). 88 + /// Issues a short-lived HS256 access JWT and a 90-day refresh JWT. 89 + pub async fn create_session( 90 + State(state): State<AppState>, 91 + Json(payload): Json<CreateSessionRequest>, 92 + ) -> Result<(StatusCode, Json<CreateSessionResponse>), ApiError> { 93 + // --- Rate limit gate --- 94 + // Check before any DB work to shed load on targeted accounts. 95 + { 96 + let mut attempts = state 97 + .failed_login_attempts 98 + .lock() 99 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "internal error"))?; 100 + if is_rate_limited(&mut attempts, &payload.identifier) { 101 + return Err(ApiError::new( 102 + ErrorCode::RateLimited, 103 + "too many failed login attempts, please try again later", 104 + )); 105 + } 106 + } 107 + 108 + // --- Resolve identifier and verify password --- 109 + // Both "account not found" and "wrong password" surface as the same error to prevent 110 + // user enumeration via distinguishable error messages. 111 + let account_opt = resolve_identifier(&state.db, &payload.identifier).await?; 112 + 113 + let account = match account_opt { 114 + Some(row) => { 115 + let auth_ok = row 116 + .password_hash 117 + .as_deref() 118 + .map(|h| !h.is_empty() && verify_password(h, &payload.password)) 119 + .unwrap_or(false); // mobile accounts (NULL password_hash) cannot use createSession 120 + if !auth_ok { 121 + let mut attempts = state 122 + .failed_login_attempts 123 + .lock() 124 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "internal error"))?; 125 + record_failure(&mut attempts, &payload.identifier); 126 + return Err(ApiError::new( 127 + ErrorCode::AuthenticationRequired, 128 + "invalid identifier or password", 129 + )); 130 + } 131 + row 132 + } 133 + None => { 134 + let mut attempts = state 135 + .failed_login_attempts 136 + .lock() 137 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "internal error"))?; 138 + record_failure(&mut attempts, &payload.identifier); 139 + return Err(ApiError::new( 140 + ErrorCode::AuthenticationRequired, 141 + "invalid identifier or password", 142 + )); 143 + } 144 + }; 145 + 146 + // --- Clear failure history on successful authentication --- 147 + { 148 + let mut attempts = state 149 + .failed_login_attempts 150 + .lock() 151 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "internal error"))?; 152 + clear_failures(&mut attempts, &payload.identifier); 153 + } 154 + 155 + // --- Issue legacy HS256 JWTs --- 156 + let now = SystemTime::now() 157 + .duration_since(UNIX_EPOCH) 158 + .unwrap_or_default() 159 + .as_secs(); 160 + 161 + // Prefer server_did as audience (what verify_hs256_access_token validates against 162 + // when configured); fall back to public_url. 163 + let aud = state 164 + .config 165 + .server_did 166 + .as_deref() 167 + .unwrap_or(&state.config.public_url) 168 + .to_string(); 169 + 170 + let access_jwt = issue_access_jwt(&state.jwt_secret, &account.did, &aud, now)?; 171 + 172 + let refresh_jti = Uuid::new_v4().to_string(); 173 + let refresh_jwt = 174 + issue_refresh_jwt(&state.jwt_secret, &account.did, &aud, &refresh_jti, now)?; 175 + 176 + // --- Persist session and refresh token atomically --- 177 + let session_id = Uuid::new_v4().to_string(); 178 + let mut tx = state.db.begin().await.map_err(|e| { 179 + tracing::error!(error = %e, "failed to begin transaction"); 180 + ApiError::new(ErrorCode::InternalError, "failed to create session") 181 + })?; 182 + 183 + sqlx::query( 184 + "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ 185 + VALUES (?, ?, NULL, NULL, datetime('now'), datetime('now', '+90 days'))", 186 + ) 187 + .bind(&session_id) 188 + .bind(&account.did) 189 + .execute(&mut *tx) 190 + .await 191 + .map_err(|e| { 192 + tracing::error!(error = %e, "failed to insert session"); 193 + ApiError::new(ErrorCode::InternalError, "failed to create session") 194 + })?; 195 + 196 + sqlx::query( 197 + "INSERT INTO refresh_tokens (jti, did, session_id, expires_at, created_at) \ 198 + VALUES (?, ?, ?, datetime('now', '+90 days'), datetime('now'))", 199 + ) 200 + .bind(&refresh_jti) 201 + .bind(&account.did) 202 + .bind(&session_id) 203 + .execute(&mut *tx) 204 + .await 205 + .map_err(|e| { 206 + tracing::error!(error = %e, "failed to insert refresh token"); 207 + ApiError::new(ErrorCode::InternalError, "failed to create session") 208 + })?; 209 + 210 + tx.commit().await.map_err(|e| { 211 + tracing::error!(error = %e, "failed to commit session transaction"); 212 + ApiError::new(ErrorCode::InternalError, "failed to create session") 213 + })?; 214 + 215 + Ok(( 216 + StatusCode::OK, 217 + Json(CreateSessionResponse { 218 + access_jwt, 219 + refresh_jwt, 220 + handle: account.handle.unwrap_or_default(), 221 + did: account.did, 222 + email: account.email, 223 + }), 224 + )) 225 + } 226 + 227 + // ── Private helpers ────────────────────────────────────────────────────────── 228 + 229 + /// Resolve a handle or DID to an active (non-deactivated) account. 230 + /// 231 + /// Returns `None` when not found; `Err` only on DB errors. 232 + async fn resolve_identifier( 233 + db: &sqlx::SqlitePool, 234 + identifier: &str, 235 + ) -> Result<Option<AccountRow>, ApiError> { 236 + if identifier.starts_with("did:") { 237 + let row: Option<(String, Option<String>, Option<String>)> = sqlx::query_as( 238 + "SELECT a.email, a.password_hash, h.handle \ 239 + FROM accounts a \ 240 + LEFT JOIN handles h ON h.did = a.did \ 241 + WHERE a.did = ? AND a.deactivated_at IS NULL \ 242 + LIMIT 1", 243 + ) 244 + .bind(identifier) 245 + .fetch_optional(db) 246 + .await 247 + .map_err(|e| { 248 + tracing::error!(error = %e, "DB error resolving DID"); 249 + ApiError::new(ErrorCode::InternalError, "failed to resolve identifier") 250 + })?; 251 + 252 + Ok(row.map(|(email, password_hash, handle)| AccountRow { 253 + did: identifier.to_string(), 254 + email, 255 + password_hash, 256 + handle, 257 + })) 258 + } else { 259 + let row: Option<(String, String, Option<String>)> = sqlx::query_as( 260 + "SELECT a.did, a.email, a.password_hash \ 261 + FROM handles h \ 262 + JOIN accounts a ON a.did = h.did \ 263 + WHERE h.handle = ? AND a.deactivated_at IS NULL \ 264 + LIMIT 1", 265 + ) 266 + .bind(identifier) 267 + .fetch_optional(db) 268 + .await 269 + .map_err(|e| { 270 + tracing::error!(error = %e, "DB error resolving handle"); 271 + ApiError::new(ErrorCode::InternalError, "failed to resolve identifier") 272 + })?; 273 + 274 + Ok(row.map(|(did, email, password_hash)| AccountRow { 275 + did, 276 + email, 277 + password_hash, 278 + handle: Some(identifier.to_string()), 279 + })) 280 + } 281 + } 282 + 283 + /// Verify `password` against a stored argon2id PHC-format hash string. 284 + fn verify_password(stored_hash: &str, password: &str) -> bool { 285 + let Ok(hash) = PasswordHash::new(stored_hash) else { 286 + return false; 287 + }; 288 + Argon2::default() 289 + .verify_password(password.as_bytes(), &hash) 290 + .is_ok() 291 + } 292 + 293 + /// Sign an HS256 access JWT with a 2-hour lifetime. 294 + fn issue_access_jwt( 295 + secret: &[u8; 32], 296 + did: &str, 297 + aud: &str, 298 + now: u64, 299 + ) -> Result<String, ApiError> { 300 + encode( 301 + &Header::new(Algorithm::HS256), 302 + &LegacyAccessClaims { 303 + scope: "com.atproto.access", 304 + sub: did.to_string(), 305 + aud: aud.to_string(), 306 + iat: now, 307 + exp: now + ACCESS_TOKEN_TTL_SECS, 308 + }, 309 + &EncodingKey::from_secret(secret), 310 + ) 311 + .map_err(|e| { 312 + tracing::error!(error = %e, "failed to sign access JWT"); 313 + ApiError::new(ErrorCode::InternalError, "failed to issue token") 314 + }) 315 + } 316 + 317 + /// Sign an HS256 refresh JWT with a 90-day lifetime. 318 + fn issue_refresh_jwt( 319 + secret: &[u8; 32], 320 + did: &str, 321 + aud: &str, 322 + jti: &str, 323 + now: u64, 324 + ) -> Result<String, ApiError> { 325 + encode( 326 + &Header::new(Algorithm::HS256), 327 + &LegacyRefreshClaims { 328 + scope: "com.atproto.refresh", 329 + sub: did.to_string(), 330 + aud: aud.to_string(), 331 + jti: jti.to_string(), 332 + iat: now, 333 + exp: now + REFRESH_TOKEN_TTL_SECS, 334 + }, 335 + &EncodingKey::from_secret(secret), 336 + ) 337 + .map_err(|e| { 338 + tracing::error!(error = %e, "failed to sign refresh JWT"); 339 + ApiError::new(ErrorCode::InternalError, "failed to issue token") 340 + }) 341 + } 342 + 343 + // ── Rate limiting ──────────────────────────────────────────────────────────── 344 + 345 + /// Returns `true` if `identifier` has had ≥ `RATE_LIMIT_MAX_FAILURES` failed login 346 + /// attempts within the last `RATE_LIMIT_WINDOW_SECS` seconds (sliding window). 347 + /// 348 + /// Prunes expired entries from the front of the deque during the check, keeping 349 + /// memory bounded without a separate cleanup goroutine. 350 + /// 351 + /// # Your turn 352 + /// 353 + /// Implement this function. The approach: 354 + /// 1. Look up the `VecDeque` for `identifier`; return `false` if absent (no prior failures). 355 + /// 2. Drop entries from the **front** that are older than `RATE_LIMIT_WINDOW_SECS`. 356 + /// 3. Return `true` if the remaining count ≥ `RATE_LIMIT_MAX_FAILURES`. 357 + /// 358 + /// Trade-off to consider: a sliding window is accurate but allocates one `Instant` per 359 + /// failure. A fixed window (keyed by `now / window_secs`) uses O(1) memory but can allow 360 + /// 2× the limit at a window boundary. Either is valid for v0.1 — pick the approach that 361 + /// makes the rate-limit test below pass. 362 + fn is_rate_limited( 363 + attempts: &mut HashMap<String, VecDeque<Instant>>, 364 + identifier: &str, 365 + ) -> bool { 366 + let deque = attempts.get_mut(identifier); 367 + if let Some(deque) = deque { 368 + let now = Instant::now(); 369 + while let Some(&oldest) = deque.front() { 370 + if now - oldest > Duration::from_secs(RATE_LIMIT_WINDOW_SECS) { 371 + deque.pop_front(); 372 + } else { 373 + break; 374 + } 375 + } 376 + return deque.len() >= RATE_LIMIT_MAX_FAILURES; 377 + } 378 + false 379 + } 380 + 381 + /// Record a new failed attempt timestamp for `identifier`. 382 + fn record_failure(attempts: &mut HashMap<String, VecDeque<Instant>>, identifier: &str) { 383 + attempts 384 + .entry(identifier.to_string()) 385 + .or_default() 386 + .push_back(Instant::now()); 387 + } 388 + 389 + /// Clear the failure history for `identifier` on successful authentication. 390 + fn clear_failures(attempts: &mut HashMap<String, VecDeque<Instant>>, identifier: &str) { 391 + attempts.remove(identifier); 392 + } 393 + 394 + // ── Tests ──────────────────────────────────────────────────────────────────── 395 + 396 + #[cfg(test)] 397 + mod tests { 398 + use super::*; 399 + use argon2::{ 400 + password_hash::{rand_core::OsRng, SaltString}, 401 + Argon2, PasswordHasher, 402 + }; 403 + use axum::{ 404 + body::Body, 405 + http::{Request, StatusCode}, 406 + }; 407 + use tower::ServiceExt; 408 + 409 + use crate::app::{app, test_state}; 410 + 411 + // ── Helpers ─────────────────────────────────────────────────────────────── 412 + 413 + fn post_create_session(identifier: &str, password: &str) -> Request<Body> { 414 + Request::builder() 415 + .method("POST") 416 + .uri("/xrpc/com.atproto.server.createSession") 417 + .header("Content-Type", "application/json") 418 + .body(Body::from(format!( 419 + r#"{{"identifier":"{identifier}","password":"{password}"}}"# 420 + ))) 421 + .unwrap() 422 + } 423 + 424 + async fn insert_account_with_password( 425 + db: &sqlx::SqlitePool, 426 + did: &str, 427 + handle: &str, 428 + email: &str, 429 + password: &str, 430 + ) { 431 + let salt = SaltString::generate(&mut OsRng); 432 + let hash = Argon2::default() 433 + .hash_password(password.as_bytes(), &salt) 434 + .unwrap() 435 + .to_string(); 436 + 437 + sqlx::query( 438 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 439 + VALUES (?, ?, ?, datetime('now'), datetime('now'))", 440 + ) 441 + .bind(did) 442 + .bind(email) 443 + .bind(&hash) 444 + .execute(db) 445 + .await 446 + .unwrap(); 447 + 448 + sqlx::query( 449 + "INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))", 450 + ) 451 + .bind(handle) 452 + .bind(did) 453 + .execute(db) 454 + .await 455 + .unwrap(); 456 + } 457 + 458 + async fn body_json(response: axum::response::Response) -> serde_json::Value { 459 + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 460 + .await 461 + .unwrap(); 462 + serde_json::from_slice(&bytes).unwrap() 463 + } 464 + 465 + // ── Happy path ──────────────────────────────────────────────────────────── 466 + 467 + #[tokio::test] 468 + async fn valid_did_returns_200_with_jwts() { 469 + let state = test_state().await; 470 + insert_account_with_password( 471 + &state.db, 472 + "did:plc:alice", 473 + "alice.test.example.com", 474 + "alice@example.com", 475 + "hunter2", 476 + ) 477 + .await; 478 + 479 + let response = app(state) 480 + .oneshot(post_create_session("did:plc:alice", "hunter2")) 481 + .await 482 + .unwrap(); 483 + 484 + assert_eq!(response.status(), StatusCode::OK); 485 + let json = body_json(response).await; 486 + assert!(json["accessJwt"].as_str().is_some(), "accessJwt required"); 487 + assert!(json["refreshJwt"].as_str().is_some(), "refreshJwt required"); 488 + assert_eq!(json["did"], "did:plc:alice"); 489 + assert_eq!(json["email"], "alice@example.com"); 490 + } 491 + 492 + #[tokio::test] 493 + async fn valid_handle_returns_handle_in_response() { 494 + let state = test_state().await; 495 + insert_account_with_password( 496 + &state.db, 497 + "did:plc:bob", 498 + "bob.test.example.com", 499 + "bob@example.com", 500 + "p@ssw0rd", 501 + ) 502 + .await; 503 + 504 + let response = app(state) 505 + .oneshot(post_create_session("bob.test.example.com", "p@ssw0rd")) 506 + .await 507 + .unwrap(); 508 + 509 + assert_eq!(response.status(), StatusCode::OK); 510 + let json = body_json(response).await; 511 + assert_eq!(json["handle"], "bob.test.example.com"); 512 + assert_eq!(json["did"], "did:plc:bob"); 513 + } 514 + 515 + #[tokio::test] 516 + async fn session_and_refresh_token_persisted_in_db() { 517 + let state = test_state().await; 518 + insert_account_with_password( 519 + &state.db, 520 + "did:plc:persist", 521 + "persist.test.example.com", 522 + "persist@example.com", 523 + "testpass", 524 + ) 525 + .await; 526 + 527 + let db = state.db.clone(); 528 + let response = app(state) 529 + .oneshot(post_create_session("did:plc:persist", "testpass")) 530 + .await 531 + .unwrap(); 532 + 533 + assert_eq!(response.status(), StatusCode::OK); 534 + 535 + let session_count: i64 = 536 + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE did = 'did:plc:persist'") 537 + .fetch_one(&db) 538 + .await 539 + .unwrap(); 540 + assert_eq!(session_count, 1, "one session row expected"); 541 + 542 + let refresh_count: i64 = 543 + sqlx::query_scalar("SELECT COUNT(*) FROM refresh_tokens WHERE did = 'did:plc:persist'") 544 + .fetch_one(&db) 545 + .await 546 + .unwrap(); 547 + assert_eq!(refresh_count, 1, "one refresh token row expected"); 548 + } 549 + 550 + #[tokio::test] 551 + async fn access_jwt_has_correct_scope() { 552 + let state = test_state().await; 553 + insert_account_with_password( 554 + &state.db, 555 + "did:plc:jwtcheck", 556 + "jwt.test.example.com", 557 + "jwt@example.com", 558 + "jwtpass", 559 + ) 560 + .await; 561 + 562 + let secret = state.jwt_secret; 563 + let response = app(state) 564 + .oneshot(post_create_session("did:plc:jwtcheck", "jwtpass")) 565 + .await 566 + .unwrap(); 567 + 568 + let json = body_json(response).await; 569 + let access_jwt = json["accessJwt"].as_str().unwrap(); 570 + 571 + // Decode without audience validation (test state has no server_did). 572 + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); 573 + validation.validate_aud = false; 574 + validation.set_required_spec_claims(&["exp", "sub"]); 575 + let data = jsonwebtoken::decode::<serde_json::Value>( 576 + access_jwt, 577 + &jsonwebtoken::DecodingKey::from_secret(&secret), 578 + &validation, 579 + ) 580 + .expect("access JWT must be valid"); 581 + 582 + assert_eq!(data.claims["scope"], "com.atproto.access"); 583 + assert_eq!(data.claims["sub"], "did:plc:jwtcheck"); 584 + } 585 + 586 + #[tokio::test] 587 + async fn refresh_jwt_has_jti_stored_in_db() { 588 + let state = test_state().await; 589 + insert_account_with_password( 590 + &state.db, 591 + "did:plc:jticheck", 592 + "jti.test.example.com", 593 + "jti@example.com", 594 + "jtipass", 595 + ) 596 + .await; 597 + 598 + let secret = state.jwt_secret; 599 + let db = state.db.clone(); 600 + let response = app(state) 601 + .oneshot(post_create_session("did:plc:jticheck", "jtipass")) 602 + .await 603 + .unwrap(); 604 + 605 + let json = body_json(response).await; 606 + let refresh_jwt = json["refreshJwt"].as_str().unwrap(); 607 + 608 + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); 609 + validation.validate_aud = false; 610 + validation.set_required_spec_claims(&["exp", "sub"]); 611 + let data = jsonwebtoken::decode::<serde_json::Value>( 612 + refresh_jwt, 613 + &jsonwebtoken::DecodingKey::from_secret(&secret), 614 + &validation, 615 + ) 616 + .expect("refresh JWT must be valid"); 617 + 618 + assert_eq!(data.claims["scope"], "com.atproto.refresh"); 619 + let jti = data.claims["jti"].as_str().expect("jti must be present"); 620 + 621 + let stored: Option<String> = 622 + sqlx::query_scalar("SELECT jti FROM refresh_tokens WHERE jti = ?") 623 + .bind(jti) 624 + .fetch_optional(&db) 625 + .await 626 + .unwrap(); 627 + assert!(stored.is_some(), "refresh jti must be persisted in DB"); 628 + } 629 + 630 + // ── Auth failure ────────────────────────────────────────────────────────── 631 + 632 + #[tokio::test] 633 + async fn wrong_password_returns_401() { 634 + let state = test_state().await; 635 + insert_account_with_password( 636 + &state.db, 637 + "did:plc:charlie", 638 + "charlie.test.example.com", 639 + "charlie@example.com", 640 + "correcthorsebatterystaple", 641 + ) 642 + .await; 643 + 644 + let response = app(state) 645 + .oneshot(post_create_session("did:plc:charlie", "wrongpassword")) 646 + .await 647 + .unwrap(); 648 + 649 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 650 + let json = body_json(response).await; 651 + assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 652 + } 653 + 654 + #[tokio::test] 655 + async fn unknown_identifier_returns_401() { 656 + let response = app(test_state().await) 657 + .oneshot(post_create_session("did:plc:nobody", "password")) 658 + .await 659 + .unwrap(); 660 + 661 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 662 + let json = body_json(response).await; 663 + assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 664 + } 665 + 666 + #[tokio::test] 667 + async fn mobile_account_without_password_returns_401() { 668 + let state = test_state().await; 669 + sqlx::query( 670 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 671 + VALUES ('did:plc:mobile', 'mobile@example.com', NULL, datetime('now'), datetime('now'))", 672 + ) 673 + .execute(&state.db) 674 + .await 675 + .unwrap(); 676 + 677 + let response = app(state) 678 + .oneshot(post_create_session("did:plc:mobile", "anypassword")) 679 + .await 680 + .unwrap(); 681 + 682 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 683 + } 684 + 685 + // ── Rate limiting (requires is_rate_limited implementation) ─────────────── 686 + // 687 + // This test will fail until you implement `is_rate_limited` above. 688 + // After 5 failures the 6th attempt should receive 429 Too Many Requests. 689 + 690 + #[tokio::test] 691 + async fn rate_limit_triggers_after_five_failures() { 692 + let state = test_state().await; 693 + 694 + // Five wrong-password attempts against a non-existent account. 695 + // Each should return 401, and each records a failure in the shared store. 696 + for i in 0..RATE_LIMIT_MAX_FAILURES { 697 + let response = app(state.clone()) 698 + .oneshot(post_create_session("did:plc:ratelimited", "wrongpassword")) 699 + .await 700 + .unwrap(); 701 + assert_eq!( 702 + response.status(), 703 + StatusCode::UNAUTHORIZED, 704 + "attempt {i} should be 401" 705 + ); 706 + } 707 + 708 + // The sixth attempt should now be rate-limited. 709 + let response = app(state) 710 + .oneshot(post_create_session("did:plc:ratelimited", "wrongpassword")) 711 + .await 712 + .unwrap(); 713 + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); 714 + } 715 + }
+2
crates/relay/src/routes/create_signing_key.rs
··· 131 131 jwt_secret: base.jwt_secret, 132 132 oauth_signing_keypair: base.oauth_signing_keypair, 133 133 dpop_nonces: base.dpop_nonces, 134 + failed_login_attempts: base.failed_login_attempts, 134 135 } 135 136 } 136 137 ··· 387 388 jwt_secret: base.jwt_secret, 388 389 oauth_signing_keypair: base.oauth_signing_keypair, 389 390 dpop_nonces: base.dpop_nonces, 391 + failed_login_attempts: base.failed_login_attempts, 390 392 }; 391 393 392 394 let response = app(state)
+1
crates/relay/src/routes/describe_server.rs
··· 133 133 jwt_secret: base.jwt_secret, 134 134 oauth_signing_keypair: base.oauth_signing_keypair, 135 135 dpop_nonces: base.dpop_nonces, 136 + failed_login_attempts: base.failed_login_attempts, 136 137 }; 137 138 138 139 let response = app(state)
+1
crates/relay/src/routes/mod.rs
··· 1 1 pub(crate) mod auth; 2 2 pub mod claim_codes; 3 + pub mod create_session; 3 4 pub mod create_account; 4 5 pub mod create_did; 5 6 pub mod create_handle;
+1
crates/relay/src/routes/test_utils.rs
··· 21 21 jwt_secret: base.jwt_secret, 22 22 oauth_signing_keypair: base.oauth_signing_keypair, 23 23 dpop_nonces: base.dpop_nonces, 24 + failed_login_attempts: base.failed_login_attempts, 24 25 } 25 26 }