use crate::error::{OAuthError, OAuthErrorCode};
use crate::pkce::Pkce;
use crate::url;
use rustlavel_auth::{AppKey, Encrypter, SessionHandle, base64, constant_time_eq, random, unix_now};
use rustlavel_core::Json;
use std::time::Duration;
pub const DEFAULT_SESSION_KEY: &str = "_oauth_state";
pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(10 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateError {
Missing,
Invalid,
Expired,
}
impl StateError {
pub fn message(self) -> &'static str {
match self {
StateError::Missing => {
"no sign-in is in progress for this browser. Start again from the sign-in button."
}
StateError::Invalid => {
"this sign-in did not start here. Start again from the sign-in button."
}
StateError::Expired => "this sign-in took too long. Start again from the sign-in button.",
}
}
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for StateError {}
impl From<StateError> for OAuthError {
fn from(error: StateError) -> OAuthError {
OAuthError::because(OAuthErrorCode::InvalidRequest, error.message())
}
}
#[derive(Debug, Clone)]
pub enum StateGuard {
Session(SessionState),
Sealed(SealedState),
}
impl StateGuard {
pub fn session(session: &SessionHandle) -> StateGuard {
StateGuard::Session(SessionState::new(session.clone()))
}
pub fn sealed(key: &AppKey) -> StateGuard {
StateGuard::Sealed(SealedState::new(key))
}
pub fn issue(&self, pkce: &Pkce) -> Result<String, OAuthError> {
match self {
StateGuard::Session(guard) => Ok(guard.issue(pkce)),
StateGuard::Sealed(guard) => guard.issue(pkce),
}
}
pub fn verify(&self, presented: Option<&str>) -> Result<Pkce, StateError> {
let presented = match presented {
Some(state) if !state.is_empty() => state,
_ => return Err(StateError::Missing),
};
match self {
StateGuard::Session(guard) => guard.verify(presented),
StateGuard::Sealed(guard) => guard.verify(presented),
}
}
pub fn lifetime(self, lifetime: Duration) -> StateGuard {
match self {
StateGuard::Session(guard) => StateGuard::Session(guard.lifetime(lifetime)),
StateGuard::Sealed(guard) => StateGuard::Sealed(guard.lifetime(lifetime)),
}
}
}
#[derive(Debug, Clone)]
pub struct SessionState {
session: SessionHandle,
key: String,
lifetime: Duration,
}
impl SessionState {
pub fn new(session: SessionHandle) -> SessionState {
SessionState {
session,
key: DEFAULT_SESSION_KEY.to_string(),
lifetime: DEFAULT_LIFETIME,
}
}
pub fn keyed(mut self, key: impl Into<String>) -> SessionState {
self.key = key.into();
self
}
pub fn lifetime(mut self, lifetime: Duration) -> SessionState {
self.lifetime = lifetime;
self
}
fn issue(&self, pkce: &Pkce) -> String {
let nonce = base64::encode_url(&random::bytes(16));
self.session.put(
self.key.clone(),
Json::object([
("state", Json::String(nonce.clone())),
("verifier", Json::String(pkce.verifier().to_string())),
("issued_at", Json::Number(unix_now() as f64)),
]),
);
nonce
}
fn verify(&self, presented: &str) -> Result<Pkce, StateError> {
let pending = self.session.forget(&self.key).ok_or(StateError::Missing)?;
let expected = pending.get("state").and_then(Json::as_str).ok_or(StateError::Invalid)?;
if !constant_time_eq(expected.as_bytes(), presented.as_bytes()) {
return Err(StateError::Invalid);
}
let issued_at = pending.get("issued_at").and_then(Json::as_f64).unwrap_or(0.0) as u64;
if unix_now().saturating_sub(issued_at) > self.lifetime.as_secs() {
return Err(StateError::Expired);
}
let verifier = pending.get("verifier").and_then(Json::as_str).ok_or(StateError::Invalid)?;
Pkce::from_verifier(verifier).map_err(|_| StateError::Invalid)
}
}
#[derive(Clone)]
pub struct SealedState {
encrypter: Encrypter,
lifetime: Duration,
}
impl SealedState {
pub fn new(key: &AppKey) -> SealedState {
SealedState {
encrypter: Encrypter::new(AppKey::from_bytes(key.derive("oauth-state"))),
lifetime: Duration::from_secs(5 * 60),
}
}
pub fn lifetime(mut self, lifetime: Duration) -> SealedState {
self.lifetime = lifetime;
self
}
fn issue(&self, pkce: &Pkce) -> Result<String, OAuthError> {
let issued_at = unix_now().to_string();
let nonce = base64::encode_url(&random::bytes(8));
let payload = url::form_encode(&[
("v", pkce.verifier()),
("t", &issued_at),
("n", &nonce),
]);
self.encrypter.encrypt(&payload).map_err(|error| {
OAuthError::server_error(format!("could not seal the OAuth state: {error}"))
})
}
fn verify(&self, presented: &str) -> Result<Pkce, StateError> {
let payload = self.encrypter.decrypt(presented).map_err(|_| StateError::Invalid)?;
let fields = url::form_decode(&payload);
let field = |name: &str| {
fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
};
let issued_at: u64 = field("t").and_then(|t| t.parse().ok()).ok_or(StateError::Invalid)?;
if unix_now().saturating_sub(issued_at) > self.lifetime.as_secs() {
return Err(StateError::Expired);
}
Pkce::from_verifier(field("v").ok_or(StateError::Invalid)?)
.map_err(|_| StateError::Invalid)
}
}
impl std::fmt::Debug for SealedState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SealedState").field("lifetime", &self.lifetime).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustlavel_auth::Session;
fn session_guard() -> StateGuard {
StateGuard::session(&SessionHandle::new(Session::new()))
}
fn sealed_guard() -> StateGuard {
StateGuard::sealed(&AppKey::from_base64(&AppKey::generate()).unwrap())
}
fn both() -> Vec<(&'static str, StateGuard)> {
vec![("session", session_guard()), ("sealed", sealed_guard())]
}
#[test]
fn a_state_this_application_issued_verifies_and_gives_back_its_verifier() {
for (mode, guard) in both() {
let pkce = Pkce::generate();
let state = guard.issue(&pkce).unwrap();
let recovered = guard.verify(Some(&state)).expect(mode);
assert_eq!(recovered.verifier(), pkce.verifier(), "{mode}");
}
}
#[test]
fn a_callback_with_no_state_at_all_is_rejected_because_that_is_login_csrf() {
for (mode, guard) in both() {
guard.issue(&Pkce::generate()).unwrap();
assert_eq!(guard.verify(None).err(), Some(StateError::Missing), "{mode}");
assert_eq!(guard.verify(Some("")).err(), Some(StateError::Missing), "{mode}");
}
}
#[test]
fn a_callback_carrying_a_state_this_application_never_issued_is_rejected() {
for (mode, guard) in both() {
guard.issue(&Pkce::generate()).unwrap();
assert!(guard.verify(Some("not-a-state-we-made")).is_err(), "{mode}");
}
}
#[test]
fn a_state_from_a_different_browsers_flow_does_not_verify() {
let victim = session_guard();
let attacker = session_guard();
victim.issue(&Pkce::generate()).unwrap();
let attackers_state = attacker.issue(&Pkce::generate()).unwrap();
assert_eq!(victim.verify(Some(&attackers_state)).err(), Some(StateError::Invalid));
}
#[test]
fn a_stateful_state_cannot_be_replayed_once_it_has_been_spent() {
let guard = session_guard();
let state = guard.issue(&Pkce::generate()).unwrap();
assert!(guard.verify(Some(&state)).is_ok());
assert_eq!(
guard.verify(Some(&state)).err(),
Some(StateError::Missing),
"a state is spent when it is checked; a replayed callback must find nothing"
);
}
#[test]
fn a_sealed_state_cannot_be_burned_which_is_why_its_life_is_short() {
let guard = sealed_guard();
let state = guard.issue(&Pkce::generate()).unwrap();
assert!(guard.verify(Some(&state)).is_ok());
assert!(guard.verify(Some(&state)).is_ok(), "documented, not accidental");
match sealed_guard() {
StateGuard::Sealed(sealed) => assert_eq!(sealed.lifetime, Duration::from_secs(300)),
_ => unreachable!(),
}
}
#[test]
fn an_expired_state_is_rejected_even_though_it_really_was_ours() {
let guards: Vec<(&str, StateGuard)> = both()
.into_iter()
.map(|(mode, guard)| (mode, guard.lifetime(Duration::from_secs(0))))
.collect();
let issued: Vec<String> =
guards.iter().map(|(_, guard)| guard.issue(&Pkce::generate()).unwrap()).collect();
std::thread::sleep(Duration::from_millis(1100));
for ((mode, guard), state) in guards.iter().zip(&issued) {
assert_eq!(guard.verify(Some(state)).err(), Some(StateError::Expired), "{mode}");
}
}
#[test]
fn a_sealed_state_cannot_be_edited_to_extend_its_own_life() {
let guard = sealed_guard();
let state = guard.issue(&Pkce::generate()).unwrap();
let mut raw = base64::decode(&state).unwrap();
raw[20] ^= 0x01;
assert_eq!(guard.verify(Some(&base64::encode_url(&raw))).err(), Some(StateError::Invalid));
}
#[test]
fn a_sealed_state_from_another_application_key_is_rejected() {
let theirs = sealed_guard().issue(&Pkce::generate()).unwrap();
assert_eq!(sealed_guard().verify(Some(&theirs)).err(), Some(StateError::Invalid));
}
#[test]
fn the_oauth_state_key_is_not_the_key_that_encrypts_cookies() {
let key = AppKey::from_base64(&AppKey::generate()).unwrap();
let cookies = Encrypter::new(key.clone());
let guard = StateGuard::sealed(&key);
let forged = cookies.encrypt(&url::form_encode(&[
("v", &"a".repeat(43)),
("t", &unix_now().to_string()),
]))
.unwrap();
assert_eq!(guard.verify(Some(&forged)).err(), Some(StateError::Invalid));
}
#[test]
fn two_flows_in_two_tabs_can_be_kept_apart() {
let session = SessionHandle::new(Session::new());
let google = SessionState::new(session.clone()).keyed("_oauth_google");
let github = SessionState::new(session).keyed("_oauth_github");
let google_state = google.issue(&Pkce::generate());
let github_state = github.issue(&Pkce::generate());
assert!(github.verify(&github_state).is_ok());
assert!(google.verify(&google_state).is_ok(), "the second flow did not evict the first");
}
#[test]
fn a_state_never_carries_the_verifier_in_the_clear() {
let pkce = Pkce::generate();
for (mode, guard) in both() {
let state = guard.issue(&pkce).unwrap();
assert!(!state.contains(pkce.verifier()), "{mode} leaked the verifier into the URL");
}
}
#[test]
fn two_states_are_never_the_same() {
for (mode, guard) in both() {
let pkce = Pkce::generate();
assert_ne!(guard.issue(&pkce).unwrap(), guard.issue(&pkce).unwrap(), "{mode}");
}
}
#[test]
fn the_refusal_says_what_to_do_and_names_no_internals() {
for error in [StateError::Missing, StateError::Invalid, StateError::Expired] {
assert!(error.message().contains("sign-in button"), "{error:?}");
let oauth: OAuthError = error.into();
assert_eq!(oauth.code, OAuthErrorCode::InvalidRequest);
}
}
}