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

fix: add consistent logging for non-UTF-8 Authorization headers in require_pending_session (MM-89 Issue #4)

require_admin_token uses inspect_err to log non-UTF-8 header encoding issues,
but require_pending_session silently dropped such errors with no logging.

Applied the same pattern from require_admin_token to require_pending_session
to ensure consistent behavior across both auth functions.

authored by malpercio.dev and committed by

Tangled 4e7c7b75 8a5e5f5d

+214 -1
+214 -1
crates/relay/src/routes/auth.rs
··· 6 6 use crate::app::AppState; 7 7 8 8 /// Information about an authenticated pending session. 9 + #[derive(Debug)] 9 10 pub struct PendingSessionInfo { 10 11 pub account_id: String, 11 12 #[allow(dead_code)] ··· 80 81 // Extract Bearer token from Authorization header. 81 82 let token = headers 82 83 .get(axum::http::header::AUTHORIZATION) 83 - .and_then(|v| v.to_str().ok()) 84 + .and_then(|v| { 85 + v.to_str() 86 + .inspect_err(|_| { 87 + tracing::warn!( 88 + "Authorization header contains non-UTF-8 bytes; treating as absent" 89 + ); 90 + }) 91 + .ok() 92 + }) 84 93 .and_then(|v| v.strip_prefix("Bearer ")) 85 94 .ok_or_else(|| { 86 95 ApiError::new( ··· 198 207 HeaderValue::from_bytes(b"Bearer \xff\xfe").unwrap(), 199 208 ); 200 209 let err = require_admin_token(&headers, &state).unwrap_err(); 210 + assert_eq!(err.status_code(), 401); 211 + } 212 + 213 + // ── require_pending_session tests ──────────────────────────────────────── 214 + 215 + #[tokio::test] 216 + async fn pending_session_missing_authorization_header_returns_401() { 217 + let state = test_state().await; 218 + let err = require_pending_session(&HeaderMap::new(), &state.db) 219 + .await 220 + .unwrap_err(); 221 + assert_eq!(err.status_code(), 401); 222 + } 223 + 224 + #[tokio::test] 225 + async fn pending_session_non_base64url_token_returns_401() { 226 + let mut headers = HeaderMap::new(); 227 + headers.insert( 228 + axum::http::header::AUTHORIZATION, 229 + "Bearer not-valid-base64url!!!".parse().unwrap(), 230 + ); 231 + let state = test_state().await; 232 + let err = require_pending_session(&headers, &state.db) 233 + .await 234 + .unwrap_err(); 235 + assert_eq!(err.status_code(), 401); 236 + } 237 + 238 + #[tokio::test] 239 + async fn pending_session_valid_unexpired_session_returns_ok() { 240 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 241 + use rand_core::{OsRng, RngCore}; 242 + use sha2::{Digest, Sha256}; 243 + use uuid::Uuid; 244 + 245 + let state = test_state().await; 246 + 247 + // Set up a claim code, pending account, device, and pending session. 248 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 249 + sqlx::query( 250 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 251 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 252 + ) 253 + .bind(&claim_code) 254 + .execute(&state.db) 255 + .await 256 + .expect("insert claim_code"); 257 + 258 + let account_id = Uuid::new_v4().to_string(); 259 + sqlx::query( 260 + "INSERT INTO pending_accounts \ 261 + (id, email, handle, tier, claim_code, created_at) \ 262 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 263 + ) 264 + .bind(&account_id) 265 + .bind(format!("test{}@example.com", &account_id[..8])) 266 + .bind(format!("test{}.example.com", &account_id[..8])) 267 + .bind(&claim_code) 268 + .execute(&state.db) 269 + .await 270 + .expect("insert pending_account"); 271 + 272 + let device_id = Uuid::new_v4().to_string(); 273 + sqlx::query( 274 + "INSERT INTO devices \ 275 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 276 + VALUES (?, ?, 'ios', 'test_pubkey', 'test_hash', datetime('now'), datetime('now'))", 277 + ) 278 + .bind(&device_id) 279 + .bind(&account_id) 280 + .execute(&state.db) 281 + .await 282 + .expect("insert device"); 283 + 284 + // Generate a valid session token. 285 + let mut token_bytes = [0u8; 32]; 286 + OsRng.fill_bytes(&mut token_bytes); 287 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 288 + let token_hash: String = Sha256::digest(token_bytes) 289 + .iter() 290 + .map(|b| format!("{b:02x}")) 291 + .collect(); 292 + 293 + sqlx::query( 294 + "INSERT INTO pending_sessions \ 295 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 296 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 297 + ) 298 + .bind(Uuid::new_v4().to_string()) 299 + .bind(&account_id) 300 + .bind(&device_id) 301 + .bind(&token_hash) 302 + .execute(&state.db) 303 + .await 304 + .expect("insert pending_session"); 305 + 306 + // Call require_pending_session with valid token. 307 + let mut headers = HeaderMap::new(); 308 + headers.insert( 309 + axum::http::header::AUTHORIZATION, 310 + format!("Bearer {session_token}").parse().unwrap(), 311 + ); 312 + 313 + let result = require_pending_session(&headers, &state.db) 314 + .await 315 + .expect("valid session should succeed"); 316 + assert_eq!(result.account_id, account_id); 317 + assert_eq!(result.device_id, device_id); 318 + } 319 + 320 + #[tokio::test] 321 + async fn pending_session_expired_session_returns_401() { 322 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 323 + use rand_core::{OsRng, RngCore}; 324 + use sha2::{Digest, Sha256}; 325 + use uuid::Uuid; 326 + 327 + let state = test_state().await; 328 + 329 + // Set up claim code, pending account, device, and expired pending session. 330 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 331 + sqlx::query( 332 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 333 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 334 + ) 335 + .bind(&claim_code) 336 + .execute(&state.db) 337 + .await 338 + .expect("insert claim_code"); 339 + 340 + let account_id = Uuid::new_v4().to_string(); 341 + sqlx::query( 342 + "INSERT INTO pending_accounts \ 343 + (id, email, handle, tier, claim_code, created_at) \ 344 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 345 + ) 346 + .bind(&account_id) 347 + .bind(format!("test{}@example.com", &account_id[..8])) 348 + .bind(format!("test{}.example.com", &account_id[..8])) 349 + .bind(&claim_code) 350 + .execute(&state.db) 351 + .await 352 + .expect("insert pending_account"); 353 + 354 + let device_id = Uuid::new_v4().to_string(); 355 + sqlx::query( 356 + "INSERT INTO devices \ 357 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 358 + VALUES (?, ?, 'ios', 'test_pubkey', 'test_hash', datetime('now'), datetime('now'))", 359 + ) 360 + .bind(&device_id) 361 + .bind(&account_id) 362 + .execute(&state.db) 363 + .await 364 + .expect("insert device"); 365 + 366 + // Generate a token but set it as expired. 367 + let mut token_bytes = [0u8; 32]; 368 + OsRng.fill_bytes(&mut token_bytes); 369 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 370 + let token_hash: String = Sha256::digest(token_bytes) 371 + .iter() 372 + .map(|b| format!("{b:02x}")) 373 + .collect(); 374 + 375 + sqlx::query( 376 + "INSERT INTO pending_sessions \ 377 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 378 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '-1 hour'))", 379 + ) 380 + .bind(Uuid::new_v4().to_string()) 381 + .bind(&account_id) 382 + .bind(&device_id) 383 + .bind(&token_hash) 384 + .execute(&state.db) 385 + .await 386 + .expect("insert pending_session"); 387 + 388 + // Call require_pending_session with expired token. 389 + let mut headers = HeaderMap::new(); 390 + headers.insert( 391 + axum::http::header::AUTHORIZATION, 392 + format!("Bearer {session_token}").parse().unwrap(), 393 + ); 394 + 395 + let err = require_pending_session(&headers, &state.db) 396 + .await 397 + .unwrap_err(); 398 + assert_eq!(err.status_code(), 401); 399 + } 400 + 401 + #[tokio::test] 402 + async fn pending_session_non_utf8_authorization_header_returns_401() { 403 + // Exercises the inspect_err / treat-as-absent path. 404 + // HeaderValue::from_bytes accepts arbitrary bytes; to_str() will fail on \xff. 405 + let state = test_state().await; 406 + let mut headers = HeaderMap::new(); 407 + headers.insert( 408 + axum::http::header::AUTHORIZATION, 409 + HeaderValue::from_bytes(b"Bearer \xff\xfe").unwrap(), 410 + ); 411 + let err = require_pending_session(&headers, &state.db) 412 + .await 413 + .unwrap_err(); 201 414 assert_eq!(err.status_code(), 401); 202 415 } 203 416 }