use serde::{Deserialize, Serialize};
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpstreamAuthSession {
pub state: String,
pub provider_id: String,
pub code_verifier: Option<String>,
pub redirect_to: Option<String>,
pub app_state: Option<String>,
pub nonce: Option<String>,
pub created_at: u64,
pub ttl_seconds: u64,
pub consumed: bool,
}
impl UpstreamAuthSession {
#[must_use]
pub fn new(
state: String,
provider_id: String,
code_verifier: Option<String>,
nonce: Option<String>,
redirect_to: Option<String>,
ttl_seconds: u64,
) -> Self {
Self::with_app_state(state, provider_id, code_verifier, nonce, redirect_to, ttl_seconds, None)
}
#[must_use]
pub fn with_app_state(
state: String,
provider_id: String,
code_verifier: Option<String>,
nonce: Option<String>,
redirect_to: Option<String>,
ttl_seconds: u64,
app_state: Option<String>,
) -> Self {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
Self {
state,
provider_id,
code_verifier,
redirect_to,
app_state,
nonce,
created_at: now,
ttl_seconds,
consumed: false,
}
}
#[must_use]
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
(now as u64) > self.created_at / 1000 + self.ttl_seconds
}
}
#[derive(Debug)]
pub enum AuthSessionError {
NotFound,
AlreadyConsumed,
Expired,
Storage(String),
Serialization(String),
}
impl core::fmt::Display for AuthSessionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NotFound => write!(f, "auth session not found"),
Self::AlreadyConsumed => write!(f, "auth session already consumed"),
Self::Expired => write!(f, "auth session has expired"),
Self::Storage(s) => write!(f, "auth session storage error: {s}"),
Self::Serialization(s) => write!(f, "auth session serialization error: {s}"),
}
}
}
impl std::error::Error for AuthSessionError {}
pub trait UpstreamAuthSessionStore: Send + Sync {
fn insert(&self, session: &UpstreamAuthSession) -> Result<(), AuthSessionError>;
fn take(&self, state: &str) -> Result<UpstreamAuthSession, AuthSessionError>;
fn delete(&self, state: &str) -> Result<(), AuthSessionError>;
}
pub const DEFAULT_AUTH_SESSION_TTL_SECS: u64 = 600;
pub mod memory {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
pub struct MemoryAuthSessionStore {
sessions: Mutex<HashMap<String, UpstreamAuthSession>>,
}
impl MemoryAuthSessionStore {
#[must_use]
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
}
}
}
impl Default for MemoryAuthSessionStore {
fn default() -> Self {
Self::new()
}
}
impl UpstreamAuthSessionStore for MemoryAuthSessionStore {
fn insert(&self, session: &UpstreamAuthSession) -> Result<(), AuthSessionError> {
let mut map = self.sessions.lock().map_err(|e| {
AuthSessionError::Storage(format!("mutex poisoned: {e}"))
})?;
map.insert(session.state.clone(), session.clone());
Ok(())
}
fn take(&self, state: &str) -> Result<UpstreamAuthSession, AuthSessionError> {
let mut map = self.sessions.lock().map_err(|e| {
AuthSessionError::Storage(format!("mutex poisoned: {e}"))
})?;
let session = map.remove(state).ok_or(AuthSessionError::NotFound)?;
if session.consumed {
return Err(AuthSessionError::AlreadyConsumed);
}
if session.is_expired() {
return Err(AuthSessionError::Expired);
}
Ok(session)
}
fn delete(&self, state: &str) -> Result<(), AuthSessionError> {
let mut map = self.sessions.lock().map_err(|e| {
AuthSessionError::Storage(format!("mutex poisoned: {e}"))
})?;
map.remove(state);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use memory::MemoryAuthSessionStore;
#[test]
fn session_insert_and_take_round_trips() {
let store = MemoryAuthSessionStore::new();
let session = UpstreamAuthSession::new(
"state-abc123".into(),
"google".into(),
Some("pkce-verifier".into()),
Some("nonce-123".into()),
Some("https://app.example.com/dashboard".into()),
600,
);
store.insert(&session).expect("insert");
let taken = store.take("state-abc123").expect("take");
assert_eq!(taken.provider_id, "google");
assert_eq!(taken.code_verifier.unwrap(), "pkce-verifier");
assert_eq!(taken.nonce.unwrap(), "nonce-123");
assert_eq!(taken.redirect_to.unwrap(), "https://app.example.com/dashboard");
}
#[test]
fn take_removes_session_one_time_only() {
let store = MemoryAuthSessionStore::new();
let session = UpstreamAuthSession::new(
"state-xyz".into(),
"github".into(),
None,
None,
None,
600,
);
store.insert(&session).expect("insert");
let _first = store.take("state-xyz").expect("first take");
let second = store.take("state-xyz");
assert!(matches!(second, Err(AuthSessionError::NotFound)));
}
#[test]
fn not_found_returns_error() {
let store = MemoryAuthSessionStore::new();
let err = store.take("nonexistent").unwrap_err();
assert!(matches!(err, AuthSessionError::NotFound));
}
#[test]
fn expired_session_returns_error() {
let store = MemoryAuthSessionStore::new();
let mut session = UpstreamAuthSession::new(
"expired-state".into(),
"google".into(),
None,
None,
None,
1, );
session.created_at = session.created_at.saturating_sub(2000);
store.insert(&session).expect("insert");
let err = store.take("expired-state").unwrap_err();
assert!(matches!(err, AuthSessionError::Expired));
}
}