Skip to main content

rustlavel_oauth/
state.rs

1//! The `state` parameter: the only thing standing between a login button and
2//! login CSRF.
3//!
4//! The attack, concretely. An attacker starts the flow with *their own* Google
5//! account, stops at the point where Google hands back a `code`, and gets a
6//! victim to load the callback URL carrying it. If the application accepts any
7//! callback it is handed, the victim's browser is now signed in as the
8//! attacker — and every note, upload and payment method the victim adds
9//! afterwards lands in an account the attacker controls. It is quieter than
10//! stealing a session and it is far easier to reach.
11//!
12//! The defence is that a callback must prove it answers a request *this*
13//! browser made. Two ways to prove it are offered here, and neither is
14//! optional: there is no constructor that skips the check.
15
16use crate::error::{OAuthError, OAuthErrorCode};
17use crate::pkce::Pkce;
18use crate::url;
19use rustlavel_auth::{AppKey, Encrypter, SessionHandle, base64, constant_time_eq, random, unix_now};
20use rustlavel_core::Json;
21use std::time::Duration;
22
23/// Where the stateful mode keeps the pending flow, unless told otherwise.
24pub const DEFAULT_SESSION_KEY: &str = "_oauth_state";
25
26/// How long a pending authorisation stays valid.
27///
28/// Long enough for somebody to read a consent screen and find their password
29/// manager; short enough that a state left in a browser tab overnight is not
30/// still a live credential.
31pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(10 * 60);
32
33/// Why a callback was refused.
34///
35/// Three variants rather than one boolean because the words shown to a visitor
36/// differ: an expired flow should offer the button again, a mismatched one
37/// should not pretend anything is recoverable.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum StateError {
40    /// No `state` came back, or none was pending — including the case where one
41    /// was pending and has already been spent.
42    Missing,
43    /// A `state` came back that this application did not issue, or that was
44    /// edited on the way.
45    Invalid,
46    /// Ours, unedited, and too old.
47    Expired,
48}
49
50impl StateError {
51    pub fn message(self) -> &'static str {
52        match self {
53            StateError::Missing => {
54                "no sign-in is in progress for this browser. Start again from the sign-in button."
55            }
56            StateError::Invalid => {
57                "this sign-in did not start here. Start again from the sign-in button."
58            }
59            StateError::Expired => "this sign-in took too long. Start again from the sign-in button.",
60        }
61    }
62}
63
64impl std::fmt::Display for StateError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.message())
67    }
68}
69
70impl std::error::Error for StateError {}
71
72/// Every state failure is `invalid_request`: the callback was malformed as a
73/// request, whatever the reason, and saying which reason to the *provider*
74/// would be describing our own defence to whoever tripped it.
75impl From<StateError> for OAuthError {
76    fn from(error: StateError) -> OAuthError {
77        OAuthError::because(OAuthErrorCode::InvalidRequest, error.message())
78    }
79}
80
81/// Issues the `state`, and checks it when the visitor comes back.
82///
83/// Both modes bind the PKCE verifier to the state, so recovering one recovers
84/// the other and a caller cannot accidentally exchange a code against a
85/// verifier from a different flow.
86#[derive(Debug, Clone)]
87pub enum StateGuard {
88    /// The state is a random nonce; the nonce and the verifier live in the
89    /// session. Prefer this: the entry is deleted when it is checked, so a
90    /// state works exactly once.
91    Session(SessionState),
92    /// The state is sealed with a key derived from `APP_KEY` and carries the
93    /// verifier and its own issue time. Nothing is stored server-side, which is
94    /// what a fleet with no shared session store needs.
95    Sealed(SealedState),
96}
97
98impl StateGuard {
99    /// The stateful mode, on the request's session.
100    pub fn session(session: &SessionHandle) -> StateGuard {
101        StateGuard::Session(SessionState::new(session.clone()))
102    }
103
104    /// The stateless mode, keyed off the application key.
105    pub fn sealed(key: &AppKey) -> StateGuard {
106        StateGuard::Sealed(SealedState::new(key))
107    }
108
109    /// A fresh `state` committing to `pkce`.
110    pub fn issue(&self, pkce: &Pkce) -> Result<String, OAuthError> {
111        match self {
112            StateGuard::Session(guard) => Ok(guard.issue(pkce)),
113            StateGuard::Sealed(guard) => guard.issue(pkce),
114        }
115    }
116
117    /// Check what came back, and recover the verifier it was issued with.
118    ///
119    /// `None` is the missing case rather than an error the caller has to spell
120    /// out, because "the query string had no `state`" is the most common shape
121    /// of the attack and must not be reachable by forgetting a check.
122    pub fn verify(&self, presented: Option<&str>) -> Result<Pkce, StateError> {
123        let presented = match presented {
124            Some(state) if !state.is_empty() => state,
125            _ => return Err(StateError::Missing),
126        };
127
128        match self {
129            StateGuard::Session(guard) => guard.verify(presented),
130            StateGuard::Sealed(guard) => guard.verify(presented),
131        }
132    }
133
134    /// How long an issued state stays valid.
135    pub fn lifetime(self, lifetime: Duration) -> StateGuard {
136        match self {
137            StateGuard::Session(guard) => StateGuard::Session(guard.lifetime(lifetime)),
138            StateGuard::Sealed(guard) => StateGuard::Sealed(guard.lifetime(lifetime)),
139        }
140    }
141}
142
143/// The stateful mode: a nonce in the session, compared on return.
144#[derive(Debug, Clone)]
145pub struct SessionState {
146    session: SessionHandle,
147    key: String,
148    lifetime: Duration,
149}
150
151impl SessionState {
152    pub fn new(session: SessionHandle) -> SessionState {
153        SessionState {
154            session,
155            key: DEFAULT_SESSION_KEY.to_string(),
156            lifetime: DEFAULT_LIFETIME,
157        }
158    }
159
160    /// Store the pending flow somewhere other than `_oauth_state`.
161    ///
162    /// Worth doing when an application offers two providers side by side: with
163    /// one key, opening Google and GitHub in two tabs leaves only the second
164    /// flow completable.
165    pub fn keyed(mut self, key: impl Into<String>) -> SessionState {
166        self.key = key.into();
167        self
168    }
169
170    pub fn lifetime(mut self, lifetime: Duration) -> SessionState {
171        self.lifetime = lifetime;
172        self
173    }
174
175    fn issue(&self, pkce: &Pkce) -> String {
176        // 128 bits from the OS CSPRNG. The state is an anti-forgery token, so
177        // it has to be unguessable, not merely unique.
178        let nonce = base64::encode_url(&random::bytes(16));
179
180        self.session.put(
181            self.key.clone(),
182            Json::object([
183                ("state", Json::String(nonce.clone())),
184                ("verifier", Json::String(pkce.verifier().to_string())),
185                ("issued_at", Json::Number(unix_now() as f64)),
186            ]),
187        );
188
189        nonce
190    }
191
192    fn verify(&self, presented: &str) -> Result<Pkce, StateError> {
193        // Taken, not read: a state that has been checked once must not check
194        // out again, or a code replayed against the callback still gets past
195        // the door. This is the whole reason the stateful mode is the default.
196        let pending = self.session.forget(&self.key).ok_or(StateError::Missing)?;
197
198        let expected = pending.get("state").and_then(Json::as_str).ok_or(StateError::Invalid)?;
199        if !constant_time_eq(expected.as_bytes(), presented.as_bytes()) {
200            return Err(StateError::Invalid);
201        }
202
203        let issued_at = pending.get("issued_at").and_then(Json::as_f64).unwrap_or(0.0) as u64;
204        if unix_now().saturating_sub(issued_at) > self.lifetime.as_secs() {
205            return Err(StateError::Expired);
206        }
207
208        let verifier = pending.get("verifier").and_then(Json::as_str).ok_or(StateError::Invalid)?;
209        Pkce::from_verifier(verifier).map_err(|_| StateError::Invalid)
210    }
211}
212
213/// The stateless mode: the state is its own storage.
214///
215/// The payload carries the verifier, the issue time and a nonce, sealed with
216/// AES-256-GCM under a key derived from `APP_KEY`. Authenticated encryption is
217/// the point: the issue time is inside the sealed blob, so a holder cannot
218/// extend their own state's life, and a forged state fails the tag check
219/// before a single field is read.
220///
221/// What it cannot do is burn a state on use — that would need somewhere to
222/// write. Within its lifetime the same state verifies twice. It is not the hole
223/// it looks like: the state's job is to prove the callback answers a request
224/// this browser made, and the *code* it arrives with is single-use at the
225/// provider, so a replayed callback fails at the token endpoint with
226/// `invalid_grant`. Still, the lifetime here is short for a reason, and an
227/// application that can reach a session store should use [`SessionState`].
228#[derive(Clone)]
229pub struct SealedState {
230    encrypter: Encrypter,
231    lifetime: Duration,
232}
233
234impl SealedState {
235    pub fn new(key: &AppKey) -> SealedState {
236        SealedState {
237            // A sub-key, not the application key: a state sealed here must
238            // never decrypt as an encrypted cookie, and an attacker who can
239            // make the application encrypt something they chose must not
240            // thereby be able to mint a state.
241            encrypter: Encrypter::new(AppKey::from_bytes(key.derive("oauth-state"))),
242            // Shorter than the stateful default because this one cannot be
243            // spent: every second it stays valid is a second it can be reused.
244            lifetime: Duration::from_secs(5 * 60),
245        }
246    }
247
248    pub fn lifetime(mut self, lifetime: Duration) -> SealedState {
249        self.lifetime = lifetime;
250        self
251    }
252
253    fn issue(&self, pkce: &Pkce) -> Result<String, OAuthError> {
254        let issued_at = unix_now().to_string();
255        // The nonce makes two states issued in the same second differ, so a
256        // state is not a stable identifier for the flow it belongs to.
257        let nonce = base64::encode_url(&random::bytes(8));
258        let payload = url::form_encode(&[
259            ("v", pkce.verifier()),
260            ("t", &issued_at),
261            ("n", &nonce),
262        ]);
263
264        self.encrypter.encrypt(&payload).map_err(|error| {
265            OAuthError::server_error(format!("could not seal the OAuth state: {error}"))
266        })
267    }
268
269    fn verify(&self, presented: &str) -> Result<Pkce, StateError> {
270        // Every decryption failure looks the same from here — wrong key, edited
271        // ciphertext, truncated base64 — which is deliberate: telling the
272        // holder which one it was is an oracle.
273        let payload = self.encrypter.decrypt(presented).map_err(|_| StateError::Invalid)?;
274        let fields = url::form_decode(&payload);
275        let field = |name: &str| {
276            fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
277        };
278
279        let issued_at: u64 = field("t").and_then(|t| t.parse().ok()).ok_or(StateError::Invalid)?;
280        if unix_now().saturating_sub(issued_at) > self.lifetime.as_secs() {
281            return Err(StateError::Expired);
282        }
283
284        Pkce::from_verifier(field("v").ok_or(StateError::Invalid)?)
285            .map_err(|_| StateError::Invalid)
286    }
287}
288
289impl std::fmt::Debug for SealedState {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("SealedState").field("lifetime", &self.lifetime).finish()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use rustlavel_auth::Session;
299
300    fn session_guard() -> StateGuard {
301        StateGuard::session(&SessionHandle::new(Session::new()))
302    }
303
304    fn sealed_guard() -> StateGuard {
305        StateGuard::sealed(&AppKey::from_base64(&AppKey::generate()).unwrap())
306    }
307
308    fn both() -> Vec<(&'static str, StateGuard)> {
309        vec![("session", session_guard()), ("sealed", sealed_guard())]
310    }
311
312    #[test]
313    fn a_state_this_application_issued_verifies_and_gives_back_its_verifier() {
314        for (mode, guard) in both() {
315            let pkce = Pkce::generate();
316            let state = guard.issue(&pkce).unwrap();
317
318            let recovered = guard.verify(Some(&state)).expect(mode);
319            assert_eq!(recovered.verifier(), pkce.verifier(), "{mode}");
320        }
321    }
322
323    #[test]
324    fn a_callback_with_no_state_at_all_is_rejected_because_that_is_login_csrf() {
325        // The attack in one line: an attacker's authorisation code, delivered to
326        // a victim's browser as a bare callback URL. Accepting it signs the
327        // victim into the attacker's account.
328        for (mode, guard) in both() {
329            guard.issue(&Pkce::generate()).unwrap();
330
331            assert_eq!(guard.verify(None).err(), Some(StateError::Missing), "{mode}");
332            assert_eq!(guard.verify(Some("")).err(), Some(StateError::Missing), "{mode}");
333        }
334    }
335
336    #[test]
337    fn a_callback_carrying_a_state_this_application_never_issued_is_rejected() {
338        for (mode, guard) in both() {
339            guard.issue(&Pkce::generate()).unwrap();
340
341            assert!(guard.verify(Some("not-a-state-we-made")).is_err(), "{mode}");
342        }
343    }
344
345    #[test]
346    fn a_state_from_a_different_browsers_flow_does_not_verify() {
347        // Two visitors, two sessions. The attacker's own legitimately-issued
348        // state must not unlock the victim's callback.
349        let victim = session_guard();
350        let attacker = session_guard();
351
352        victim.issue(&Pkce::generate()).unwrap();
353        let attackers_state = attacker.issue(&Pkce::generate()).unwrap();
354
355        assert_eq!(victim.verify(Some(&attackers_state)).err(), Some(StateError::Invalid));
356    }
357
358    #[test]
359    fn a_stateful_state_cannot_be_replayed_once_it_has_been_spent() {
360        let guard = session_guard();
361        let state = guard.issue(&Pkce::generate()).unwrap();
362
363        assert!(guard.verify(Some(&state)).is_ok());
364        assert_eq!(
365            guard.verify(Some(&state)).err(),
366            Some(StateError::Missing),
367            "a state is spent when it is checked; a replayed callback must find nothing"
368        );
369    }
370
371    #[test]
372    fn a_sealed_state_cannot_be_burned_which_is_why_its_life_is_short() {
373        // Stating the limitation rather than hiding it. The second callback is
374        // stopped at the token endpoint instead, because the code is single-use
375        // there — see the type's documentation.
376        let guard = sealed_guard();
377        let state = guard.issue(&Pkce::generate()).unwrap();
378
379        assert!(guard.verify(Some(&state)).is_ok());
380        assert!(guard.verify(Some(&state)).is_ok(), "documented, not accidental");
381
382        match sealed_guard() {
383            StateGuard::Sealed(sealed) => assert_eq!(sealed.lifetime, Duration::from_secs(300)),
384            _ => unreachable!(),
385        }
386    }
387
388    #[test]
389    fn an_expired_state_is_rejected_even_though_it_really_was_ours() {
390        let guards: Vec<(&str, StateGuard)> = both()
391            .into_iter()
392            .map(|(mode, guard)| (mode, guard.lifetime(Duration::from_secs(0))))
393            .collect();
394        let issued: Vec<String> =
395            guards.iter().map(|(_, guard)| guard.issue(&Pkce::generate()).unwrap()).collect();
396
397        // `unix_now` has one-second resolution, so a zero lifetime is only
398        // reliably in the past once the clock has ticked. Slept once for both
399        // guards rather than once each.
400        std::thread::sleep(Duration::from_millis(1100));
401
402        for ((mode, guard), state) in guards.iter().zip(&issued) {
403            assert_eq!(guard.verify(Some(state)).err(), Some(StateError::Expired), "{mode}");
404        }
405    }
406
407    #[test]
408    fn a_sealed_state_cannot_be_edited_to_extend_its_own_life() {
409        // The issue time is inside the authenticated blob, so there is no byte
410        // to edit: GCM authenticates the whole ciphertext, and a flipped bit
411        // fails the tag rather than yielding a later timestamp.
412        let guard = sealed_guard();
413        let state = guard.issue(&Pkce::generate()).unwrap();
414
415        let mut raw = base64::decode(&state).unwrap();
416        // A byte well inside the ciphertext: past the version byte and the
417        // 12-byte nonce, and nowhere near the trailing tag.
418        raw[20] ^= 0x01;
419
420        assert_eq!(guard.verify(Some(&base64::encode_url(&raw))).err(), Some(StateError::Invalid));
421    }
422
423    #[test]
424    fn a_sealed_state_from_another_application_key_is_rejected() {
425        let theirs = sealed_guard().issue(&Pkce::generate()).unwrap();
426
427        assert_eq!(sealed_guard().verify(Some(&theirs)).err(), Some(StateError::Invalid));
428    }
429
430    #[test]
431    fn the_oauth_state_key_is_not_the_key_that_encrypts_cookies() {
432        // Otherwise an application that encrypts anything a visitor controls is
433        // an application that lets them mint a state.
434        let key = AppKey::from_base64(&AppKey::generate()).unwrap();
435        let cookies = Encrypter::new(key.clone());
436        let guard = StateGuard::sealed(&key);
437
438        let forged = cookies.encrypt(&url::form_encode(&[
439            ("v", &"a".repeat(43)),
440            ("t", &unix_now().to_string()),
441        ]))
442        .unwrap();
443
444        assert_eq!(guard.verify(Some(&forged)).err(), Some(StateError::Invalid));
445    }
446
447    #[test]
448    fn two_flows_in_two_tabs_can_be_kept_apart() {
449        let session = SessionHandle::new(Session::new());
450        let google = SessionState::new(session.clone()).keyed("_oauth_google");
451        let github = SessionState::new(session).keyed("_oauth_github");
452
453        let google_state = google.issue(&Pkce::generate());
454        let github_state = github.issue(&Pkce::generate());
455
456        assert!(github.verify(&github_state).is_ok());
457        assert!(google.verify(&google_state).is_ok(), "the second flow did not evict the first");
458    }
459
460    #[test]
461    fn a_state_never_carries_the_verifier_in_the_clear() {
462        // The state travels in a URL, through the provider, into browser
463        // history and access logs. A verifier visible there would undo PKCE.
464        let pkce = Pkce::generate();
465
466        for (mode, guard) in both() {
467            let state = guard.issue(&pkce).unwrap();
468            assert!(!state.contains(pkce.verifier()), "{mode} leaked the verifier into the URL");
469        }
470    }
471
472    #[test]
473    fn two_states_are_never_the_same() {
474        for (mode, guard) in both() {
475            let pkce = Pkce::generate();
476            assert_ne!(guard.issue(&pkce).unwrap(), guard.issue(&pkce).unwrap(), "{mode}");
477        }
478    }
479
480    #[test]
481    fn the_refusal_says_what_to_do_and_names_no_internals() {
482        for error in [StateError::Missing, StateError::Invalid, StateError::Expired] {
483            assert!(error.message().contains("sign-in button"), "{error:?}");
484
485            let oauth: OAuthError = error.into();
486            assert_eq!(oauth.code, OAuthErrorCode::InvalidRequest);
487        }
488    }
489}