Skip to main content

pylon_auth/
phone.rs

1//! Phone / SMS magic-code sign-in.
2//!
3//! Mirror of the email magic-code flow but with phone numbers as
4//! the identity. Same code shape (6-digit numeric), same expiry
5//! (10 min), same single-use semantics. Pluggable SMS transport
6//! lets apps use Twilio / MessageBird / a webhook.
7//!
8//! Phone numbers are E.164-normalized (`+15551234567`) before any
9//! storage / lookup so case + whitespace + formatting differences
10//! collapse to one identity.
11//!
12//! Workflow:
13//!   1. POST /api/auth/phone/send-code  { phone }
14//!      → SMS arrives with `Your sign-in code is 123456`.
15//!   2. POST /api/auth/phone/verify     { phone, code }
16//!      → returns the session token, same shape as magic-email.
17//!
18//! Apps that need full E.164 validation (libphonenumber-style)
19//! should plug a custom validator before calling `Phone::normalize`.
20
21use std::collections::HashMap;
22use std::sync::Mutex;
23
24use serde::{Deserialize, Serialize};
25
26/// Stored pending code. Same shape as MagicCode but keyed on phone.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PhoneCode {
29    pub phone: String,
30    pub code: String,
31    /// Unix-epoch seconds when this code was minted. Used by the
32    /// resend-throttle calculation. Stored explicitly (not derived
33    /// from `expires_at - TTL`) so a TTL change between mint and
34    /// throttle-check doesn't shift the throttle window.
35    pub issued_at: u64,
36    pub expires_at: u64,
37    pub attempts: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum PhoneCodeError {
42    NotFound,
43    Expired,
44    BadCode,
45    TooManyAttempts,
46    Throttled { retry_after_secs: u64 },
47    InvalidPhone,
48}
49
50impl std::fmt::Display for PhoneCodeError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::NotFound => f.write_str("no pending code for this phone"),
54            Self::Expired => f.write_str("code expired"),
55            Self::BadCode => f.write_str("wrong code"),
56            Self::TooManyAttempts => f.write_str("too many failed attempts; request a new code"),
57            Self::Throttled { retry_after_secs } => {
58                write!(f, "wait {retry_after_secs}s before requesting another code")
59            }
60            Self::InvalidPhone => f.write_str("phone number not in E.164 format"),
61        }
62    }
63}
64
65pub trait PhoneCodeBackend: Send + Sync {
66    fn put(&self, phone: &str, code: &PhoneCode);
67    fn get(&self, phone: &str) -> Option<PhoneCode>;
68    fn remove(&self, phone: &str);
69    fn put_attempts(&self, phone: &str, attempts: u32);
70}
71
72pub struct InMemoryPhoneCodeBackend {
73    codes: Mutex<HashMap<String, PhoneCode>>,
74}
75
76impl Default for InMemoryPhoneCodeBackend {
77    fn default() -> Self {
78        Self {
79            codes: Mutex::new(HashMap::new()),
80        }
81    }
82}
83
84impl PhoneCodeBackend for InMemoryPhoneCodeBackend {
85    fn put(&self, phone: &str, code: &PhoneCode) {
86        self.codes
87            .lock()
88            .unwrap()
89            .insert(phone.to_string(), code.clone());
90    }
91    fn get(&self, phone: &str) -> Option<PhoneCode> {
92        self.codes.lock().unwrap().get(phone).cloned()
93    }
94    fn remove(&self, phone: &str) {
95        self.codes.lock().unwrap().remove(phone);
96    }
97    fn put_attempts(&self, phone: &str, attempts: u32) {
98        if let Some(c) = self.codes.lock().unwrap().get_mut(phone) {
99            c.attempts = attempts;
100        }
101    }
102}
103
104pub struct PhoneCodeStore {
105    backend: Box<dyn PhoneCodeBackend>,
106}
107
108impl Default for PhoneCodeStore {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl PhoneCodeStore {
115    const TTL_SECS: u64 = 10 * 60;
116    const RESEND_THROTTLE_SECS: u64 = 30;
117    const MAX_ATTEMPTS: u32 = 5;
118
119    pub fn new() -> Self {
120        Self::with_backend(Box::new(InMemoryPhoneCodeBackend::default()))
121    }
122    pub fn with_backend(backend: Box<dyn PhoneCodeBackend>) -> Self {
123        Self { backend }
124    }
125
126    /// Generate + store a 6-digit code, returning it for the caller
127    /// to send via SMS. Throttled to one request per 30 seconds per
128    /// phone to make SMS-cost-bombing impractical.
129    pub fn try_create(&self, phone: &str) -> Result<String, PhoneCodeError> {
130        let normalized = normalize(phone).ok_or(PhoneCodeError::InvalidPhone)?;
131        let now = now_secs();
132        if let Some(existing) = self.backend.get(&normalized) {
133            if now - existing.issued_at < Self::RESEND_THROTTLE_SECS {
134                return Err(PhoneCodeError::Throttled {
135                    retry_after_secs: Self::RESEND_THROTTLE_SECS - (now - existing.issued_at),
136                });
137            }
138        }
139        let code = generate_code();
140        let pc = PhoneCode {
141            phone: normalized.clone(),
142            code: code.clone(),
143            issued_at: now,
144            expires_at: now + Self::TTL_SECS,
145            attempts: 0,
146        };
147        self.backend.put(&normalized, &pc);
148        Ok(code)
149    }
150
151    /// Verify a code. Equalizes timing between "no pending code"
152    /// and "wrong code" paths by always running a constant-time
153    /// compare against a dummy value when the lookup misses, so
154    /// an attacker can't enumerate which phone numbers have
155    /// requested codes.
156    pub fn try_verify(&self, phone: &str, code: &str) -> Result<(), PhoneCodeError> {
157        let normalized = normalize(phone).ok_or(PhoneCodeError::InvalidPhone)?;
158        let entry = self.backend.get(&normalized);
159        match entry {
160            None => {
161                // P3-2 (codex Wave-5 review): equalize timing.
162                let _ = crate::constant_time_eq(b"000000", code.trim().as_bytes());
163                Err(PhoneCodeError::NotFound)
164            }
165            Some(mut entry) => {
166                if entry.expires_at <= now_secs() {
167                    self.backend.remove(&normalized);
168                    return Err(PhoneCodeError::Expired);
169                }
170                if entry.attempts >= Self::MAX_ATTEMPTS {
171                    self.backend.remove(&normalized);
172                    return Err(PhoneCodeError::TooManyAttempts);
173                }
174                let ok = crate::constant_time_eq(entry.code.as_bytes(), code.trim().as_bytes());
175                if ok {
176                    self.backend.remove(&normalized);
177                    Ok(())
178                } else {
179                    entry.attempts += 1;
180                    self.backend.put_attempts(&normalized, entry.attempts);
181                    if entry.attempts >= Self::MAX_ATTEMPTS {
182                        self.backend.remove(&normalized);
183                        return Err(PhoneCodeError::TooManyAttempts);
184                    }
185                    Err(PhoneCodeError::BadCode)
186                }
187            }
188        }
189    }
190}
191
192/// Normalize a user-supplied phone number to E.164. Strips spaces,
193/// dashes, parens, dots. Leading `+` required. ASCII digits only.
194/// Returns None for malformed input.
195pub fn normalize(input: &str) -> Option<String> {
196    let mut out = String::with_capacity(input.len());
197    let mut started = false;
198    for ch in input.chars() {
199        match ch {
200            '+' if !started => {
201                out.push('+');
202                started = true;
203            }
204            '0'..='9' => {
205                out.push(ch);
206                started = true;
207            }
208            ' ' | '-' | '.' | '(' | ')' | '\t' => continue,
209            _ => return None,
210        }
211    }
212    if !out.starts_with('+') || out.len() < 8 || out.len() > 16 {
213        return None;
214    }
215    Some(out)
216}
217
218/// Generate a zero-padded 6-digit code.
219fn generate_code() -> String {
220    use rand::Rng;
221    format!("{:06}", rand::thread_rng().gen_range(0..1_000_000))
222}
223
224fn now_secs() -> u64 {
225    use std::time::{SystemTime, UNIX_EPOCH};
226    SystemTime::now()
227        .duration_since(UNIX_EPOCH)
228        .unwrap_or_default()
229        .as_secs()
230}
231
232// ---------------------------------------------------------------------------
233// SMS transport — pluggable, same shape as the email transport
234// ---------------------------------------------------------------------------
235
236/// SMS sender. Apps register a Twilio/MessageBird transport at
237/// startup; tests use [`NullSmsTransport`].
238pub trait SmsSender: Send + Sync {
239    fn send_sms(&self, phone: &str, body: &str) -> Result<(), String>;
240}
241
242/// No-op transport for tests + the in-memory dev runtime.
243pub struct NullSmsTransport;
244
245impl SmsSender for NullSmsTransport {
246    fn send_sms(&self, _phone: &str, _body: &str) -> Result<(), String> {
247        Ok(())
248    }
249}
250
251/// Twilio REST API transport. Reads PYLON_TWILIO_ACCOUNT_SID +
252/// PYLON_TWILIO_AUTH_TOKEN + PYLON_TWILIO_FROM at construction.
253/// `from` MUST be a verified Twilio number / messaging service id.
254pub struct TwilioSmsTransport {
255    account_sid: String,
256    auth_token: String,
257    from: String,
258}
259
260impl TwilioSmsTransport {
261    pub fn from_env() -> Option<Self> {
262        Some(Self {
263            account_sid: std::env::var("PYLON_TWILIO_ACCOUNT_SID").ok()?,
264            auth_token: std::env::var("PYLON_TWILIO_AUTH_TOKEN").ok()?,
265            from: std::env::var("PYLON_TWILIO_FROM").ok()?,
266        })
267    }
268}
269
270impl SmsSender for TwilioSmsTransport {
271    fn send_sms(&self, phone: &str, body: &str) -> Result<(), String> {
272        let url = format!(
273            "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
274            self.account_sid
275        );
276        let form = format!(
277            "From={}&To={}&Body={}",
278            url_encode(&self.from),
279            url_encode(phone),
280            url_encode(body),
281        );
282        use base64::{engine::general_purpose::STANDARD, Engine};
283        let basic = STANDARD.encode(format!("{}:{}", self.account_sid, self.auth_token).as_bytes());
284        let agent = ureq::AgentBuilder::new()
285            .timeout_connect(std::time::Duration::from_secs(10))
286            .timeout_read(std::time::Duration::from_secs(10))
287            .build();
288        match agent
289            .post(&url)
290            .set("Content-Type", "application/x-www-form-urlencoded")
291            .set("Authorization", &format!("Basic {basic}"))
292            .send_string(&form)
293        {
294            Ok(_) => Ok(()),
295            Err(ureq::Error::Status(code, r)) => {
296                let body = r.into_string().unwrap_or_default();
297                Err(format!("twilio HTTP {code}: {body}"))
298            }
299            Err(e) => Err(format!("twilio: {e}")),
300        }
301    }
302}
303
304fn url_encode(s: &str) -> String {
305    let mut out = String::with_capacity(s.len());
306    for b in s.bytes() {
307        match b {
308            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
309                out.push(b as char)
310            }
311            _ => out.push_str(&format!("%{b:02X}")),
312        }
313    }
314    out
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn normalize_strips_formatting() {
323        assert_eq!(normalize("+1 (555) 123-4567"), Some("+15551234567".into()));
324        assert_eq!(normalize("+44 20 7946 0958"), Some("+442079460958".into()));
325        assert_eq!(normalize("+1.555.123.4567"), Some("+15551234567".into()));
326    }
327
328    #[test]
329    fn normalize_rejects_no_plus() {
330        assert!(normalize("5551234567").is_none());
331    }
332
333    #[test]
334    fn normalize_rejects_letters() {
335        assert!(normalize("+1-555-CALL-NOW").is_none());
336    }
337
338    #[test]
339    fn normalize_length_bounds() {
340        assert!(normalize("+1234").is_none()); // too short
341        assert!(normalize("+12345678901234567").is_none()); // too long
342    }
343
344    #[test]
345    fn create_and_verify_round_trip() {
346        let store = PhoneCodeStore::new();
347        let code = store.try_create("+15551234567").unwrap();
348        assert_eq!(code.len(), 6);
349        assert!(store.try_verify("+15551234567", &code).is_ok());
350        // Single-use.
351        assert_eq!(
352            store.try_verify("+15551234567", &code).unwrap_err(),
353            PhoneCodeError::NotFound
354        );
355    }
356
357    #[test]
358    fn verify_rejects_wrong_code() {
359        let store = PhoneCodeStore::new();
360        let _ = store.try_create("+15551234567").unwrap();
361        assert_eq!(
362            store.try_verify("+15551234567", "000000").unwrap_err(),
363            PhoneCodeError::BadCode
364        );
365    }
366
367    #[test]
368    fn too_many_attempts_locks() {
369        let store = PhoneCodeStore::new();
370        let _ = store.try_create("+15551234567").unwrap();
371        for _ in 0..PhoneCodeStore::MAX_ATTEMPTS - 1 {
372            let _ = store.try_verify("+15551234567", "000000");
373        }
374        // Last failure flips to TooManyAttempts.
375        assert_eq!(
376            store.try_verify("+15551234567", "000000").unwrap_err(),
377            PhoneCodeError::TooManyAttempts
378        );
379    }
380
381    #[test]
382    fn invalid_phone_rejected() {
383        let store = PhoneCodeStore::new();
384        assert_eq!(
385            store.try_create("not-a-number").unwrap_err(),
386            PhoneCodeError::InvalidPhone
387        );
388    }
389
390    #[test]
391    fn normalization_collapses_formatting_at_send() {
392        // Different formatted inputs map to the same store key so a
393        // resend uses the same throttle bucket.
394        let store = PhoneCodeStore::new();
395        let _ = store.try_create("+1 555 123 4567").unwrap();
396        // Same phone, different formatting → throttled.
397        let err = store.try_create("+15551234567").unwrap_err();
398        assert!(matches!(err, PhoneCodeError::Throttled { .. }));
399    }
400}