1use 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
23pub const DEFAULT_SESSION_KEY: &str = "_oauth_state";
25
26pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(10 * 60);
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum StateError {
40 Missing,
43 Invalid,
46 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
72impl From<StateError> for OAuthError {
76 fn from(error: StateError) -> OAuthError {
77 OAuthError::because(OAuthErrorCode::InvalidRequest, error.message())
78 }
79}
80
81#[derive(Debug, Clone)]
87pub enum StateGuard {
88 Session(SessionState),
92 Sealed(SealedState),
96}
97
98impl StateGuard {
99 pub fn session(session: &SessionHandle) -> StateGuard {
101 StateGuard::Session(SessionState::new(session.clone()))
102 }
103
104 pub fn sealed(key: &AppKey) -> StateGuard {
106 StateGuard::Sealed(SealedState::new(key))
107 }
108
109 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 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 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#[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 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 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 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#[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 encrypter: Encrypter::new(AppKey::from_bytes(key.derive("oauth-state"))),
242 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 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 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 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 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 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 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 let guard = sealed_guard();
413 let state = guard.issue(&Pkce::generate()).unwrap();
414
415 let mut raw = base64::decode(&state).unwrap();
416 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 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 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}