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    // Wave-5 codex P3: E.164 country codes can never start with 0.
216    // Reject `+0...` outright instead of accepting and routing to
217    // a phone number that doesn't exist on any carrier.
218    if out.as_bytes().get(1) == Some(&b'0') {
219        return None;
220    }
221    Some(out)
222}
223
224/// Generate a zero-padded 6-digit code.
225fn generate_code() -> String {
226    use rand::Rng;
227    format!("{:06}", rand::thread_rng().gen_range(0..1_000_000))
228}
229
230fn now_secs() -> u64 {
231    use std::time::{SystemTime, UNIX_EPOCH};
232    SystemTime::now()
233        .duration_since(UNIX_EPOCH)
234        .unwrap_or_default()
235        .as_secs()
236}
237
238// ---------------------------------------------------------------------------
239// SMS transport — pluggable, same shape as the email transport
240// ---------------------------------------------------------------------------
241
242/// SMS sender. Apps register a Twilio/MessageBird transport at
243/// startup; tests use [`NullSmsTransport`].
244pub trait SmsSender: Send + Sync {
245    fn send_sms(&self, phone: &str, body: &str) -> Result<(), String>;
246}
247
248/// No-op transport for tests + the in-memory dev runtime.
249pub struct NullSmsTransport;
250
251impl SmsSender for NullSmsTransport {
252    fn send_sms(&self, _phone: &str, _body: &str) -> Result<(), String> {
253        Ok(())
254    }
255}
256
257/// Twilio REST API transport. Reads PYLON_TWILIO_ACCOUNT_SID +
258/// PYLON_TWILIO_AUTH_TOKEN + PYLON_TWILIO_FROM at construction.
259/// `from` MUST be a verified Twilio number / messaging service id.
260pub struct TwilioSmsTransport {
261    account_sid: String,
262    auth_token: String,
263    from: String,
264}
265
266impl TwilioSmsTransport {
267    pub fn from_env() -> Option<Self> {
268        Some(Self {
269            account_sid: std::env::var("PYLON_TWILIO_ACCOUNT_SID").ok()?,
270            auth_token: std::env::var("PYLON_TWILIO_AUTH_TOKEN").ok()?,
271            from: std::env::var("PYLON_TWILIO_FROM").ok()?,
272        })
273    }
274}
275
276impl SmsSender for TwilioSmsTransport {
277    fn send_sms(&self, phone: &str, body: &str) -> Result<(), String> {
278        let url = format!(
279            "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
280            self.account_sid
281        );
282        let form = format!(
283            "From={}&To={}&Body={}",
284            url_encode(&self.from),
285            url_encode(phone),
286            url_encode(body),
287        );
288        use base64::{engine::general_purpose::STANDARD, Engine};
289        let basic = STANDARD.encode(format!("{}:{}", self.account_sid, self.auth_token).as_bytes());
290        let agent = ureq::AgentBuilder::new()
291            .timeout_connect(std::time::Duration::from_secs(10))
292            .timeout_read(std::time::Duration::from_secs(10))
293            .build();
294        match agent
295            .post(&url)
296            .set("Content-Type", "application/x-www-form-urlencoded")
297            .set("Authorization", &format!("Basic {basic}"))
298            .send_string(&form)
299        {
300            Ok(_) => Ok(()),
301            Err(ureq::Error::Status(code, r)) => {
302                let body = r.into_string().unwrap_or_default();
303                Err(format!("twilio HTTP {code}: {body}"))
304            }
305            Err(e) => Err(format!("twilio: {e}")),
306        }
307    }
308}
309
310fn url_encode(s: &str) -> String {
311    let mut out = String::with_capacity(s.len());
312    for b in s.bytes() {
313        match b {
314            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
315                out.push(b as char)
316            }
317            _ => out.push_str(&format!("%{b:02X}")),
318        }
319    }
320    out
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn normalize_strips_formatting() {
329        assert_eq!(normalize("+1 (555) 123-4567"), Some("+15551234567".into()));
330        assert_eq!(normalize("+44 20 7946 0958"), Some("+442079460958".into()));
331        assert_eq!(normalize("+1.555.123.4567"), Some("+15551234567".into()));
332    }
333
334    #[test]
335    fn normalize_rejects_no_plus() {
336        assert!(normalize("5551234567").is_none());
337    }
338
339    #[test]
340    fn normalize_rejects_letters() {
341        assert!(normalize("+1-555-CALL-NOW").is_none());
342    }
343
344    #[test]
345    fn normalize_length_bounds() {
346        assert!(normalize("+1234").is_none()); // too short
347        assert!(normalize("+12345678901234567").is_none()); // too long
348    }
349
350    /// Wave-5 codex P3 regression: E.164 country codes can never
351    /// start with 0. `+0...` should be rejected even though it
352    /// passes the length + plus checks.
353    #[test]
354    fn normalize_rejects_zero_country_code() {
355        assert!(normalize("+0123456789").is_none());
356        assert!(normalize("+0 12 345 6789").is_none());
357    }
358
359    #[test]
360    fn create_and_verify_round_trip() {
361        let store = PhoneCodeStore::new();
362        let code = store.try_create("+15551234567").unwrap();
363        assert_eq!(code.len(), 6);
364        assert!(store.try_verify("+15551234567", &code).is_ok());
365        // Single-use.
366        assert_eq!(
367            store.try_verify("+15551234567", &code).unwrap_err(),
368            PhoneCodeError::NotFound
369        );
370    }
371
372    #[test]
373    fn verify_rejects_wrong_code() {
374        let store = PhoneCodeStore::new();
375        let _ = store.try_create("+15551234567").unwrap();
376        assert_eq!(
377            store.try_verify("+15551234567", "000000").unwrap_err(),
378            PhoneCodeError::BadCode
379        );
380    }
381
382    #[test]
383    fn too_many_attempts_locks() {
384        let store = PhoneCodeStore::new();
385        let _ = store.try_create("+15551234567").unwrap();
386        for _ in 0..PhoneCodeStore::MAX_ATTEMPTS - 1 {
387            let _ = store.try_verify("+15551234567", "000000");
388        }
389        // Last failure flips to TooManyAttempts.
390        assert_eq!(
391            store.try_verify("+15551234567", "000000").unwrap_err(),
392            PhoneCodeError::TooManyAttempts
393        );
394    }
395
396    #[test]
397    fn invalid_phone_rejected() {
398        let store = PhoneCodeStore::new();
399        assert_eq!(
400            store.try_create("not-a-number").unwrap_err(),
401            PhoneCodeError::InvalidPhone
402        );
403    }
404
405    #[test]
406    fn normalization_collapses_formatting_at_send() {
407        // Different formatted inputs map to the same store key so a
408        // resend uses the same throttle bucket.
409        let store = PhoneCodeStore::new();
410        let _ = store.try_create("+1 555 123 4567").unwrap();
411        // Same phone, different formatting → throttled.
412        let err = store.try_create("+15551234567").unwrap_err();
413        assert!(matches!(err, PhoneCodeError::Throttled { .. }));
414    }
415}