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

feat(relay): add require_pending_session auth helper (MM-89)

authored by malpercio.dev and committed by

Tangled 20fd1d5a 111a155d

+75
+75
crates/relay/src/routes/auth.rs
··· 5 5 6 6 use crate::app::AppState; 7 7 8 + /// Information about an authenticated pending session. 9 + pub struct PendingSessionInfo { 10 + pub account_id: String, 11 + pub device_id: String, 12 + } 13 + 8 14 /// Validate the admin Bearer token from request headers. 9 15 /// 10 16 /// Returns `Ok(())` when the token is present, has the `"Bearer "` prefix, and the ··· 50 56 } 51 57 52 58 Ok(()) 59 + } 60 + 61 + /// Authenticate a `pending_session` Bearer token. 62 + /// 63 + /// Extracts the Bearer token from the Authorization header, SHA-256 hashes the raw 64 + /// decoded bytes (matching the storage format from `POST /v1/accounts/mobile`), and 65 + /// queries `pending_sessions` for a matching, unexpired row. 66 + /// 67 + /// # Errors 68 + /// Returns `ApiError::Unauthorized` if: 69 + /// - The Authorization header is missing 70 + /// - The token is not valid base64url 71 + /// - No unexpired session matches the token hash 72 + pub async fn require_pending_session( 73 + headers: &HeaderMap, 74 + db: &sqlx::SqlitePool, 75 + ) -> Result<PendingSessionInfo, ApiError> { 76 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 77 + use sha2::{Digest, Sha256}; 78 + 79 + // Extract Bearer token from Authorization header. 80 + let token = headers 81 + .get(axum::http::header::AUTHORIZATION) 82 + .and_then(|v| v.to_str().ok()) 83 + .and_then(|v| v.strip_prefix("Bearer ")) 84 + .ok_or_else(|| { 85 + ApiError::new( 86 + ErrorCode::Unauthorized, 87 + "missing or invalid Authorization header", 88 + ) 89 + })?; 90 + 91 + // Decode base64url → raw bytes, then SHA-256 hash → hex string. 92 + // Matches the storage format written by POST /v1/accounts/mobile. 93 + let token_bytes = URL_SAFE_NO_PAD.decode(token).map_err(|_| { 94 + ApiError::new( 95 + ErrorCode::Unauthorized, 96 + "invalid session token", 97 + ) 98 + })?; 99 + let token_hash: String = Sha256::digest(&token_bytes) 100 + .iter() 101 + .map(|b| format!("{b:02x}")) 102 + .collect(); 103 + 104 + // Look up the session by hash, rejecting expired sessions. 105 + let row: Option<(String, String)> = sqlx::query_as( 106 + "SELECT account_id, device_id FROM pending_sessions \ 107 + WHERE token_hash = ? AND expires_at > datetime('now')", 108 + ) 109 + .bind(&token_hash) 110 + .fetch_optional(db) 111 + .await 112 + .map_err(|e| { 113 + tracing::error!(error = %e, "failed to query pending session"); 114 + ApiError::new( 115 + ErrorCode::InternalError, 116 + "session lookup failed", 117 + ) 118 + })?; 119 + 120 + let (account_id, device_id) = row.ok_or_else(|| { 121 + ApiError::new( 122 + ErrorCode::Unauthorized, 123 + "invalid or expired session token", 124 + ) 125 + })?; 126 + 127 + Ok(PendingSessionInfo { account_id, device_id }) 53 128 } 54 129 55 130 #[cfg(test)]