use std::future::{self, Future};
use tokio::sync::broadcast;
use crate::shared::{HttpError, HttpResult};
use super::{revocation::RevocationUnavailable, AuthRevocation, AuthSession, AuthState};
pub(crate) enum PendingStreamAuth {
Public,
Private(broadcast::Receiver<AuthRevocation>),
}
impl PendingStreamAuth {
pub(crate) async fn subscribe(
is_private: bool,
auth_state: &AuthState,
) -> Result<Self, RevocationUnavailable> {
if is_private {
Ok(Self::Private(
auth_state.revocation_listener.subscribe().await?,
))
} else {
Ok(Self::Public)
}
}
pub(crate) async fn authorize(
self,
session: Option<AuthSession>,
auth_state: &AuthState,
) -> HttpResult<StreamAuth> {
let mut stream_auth = match (self, session) {
(Self::Private(revocation_rx), Some(session)) => {
auth_state.validate_private_stream_session(&session).await?;
StreamAuth::Private {
session: Box::new(session),
revocation_rx,
}
}
(Self::Private(_), None) => return Err(HttpError::unauthorized()),
(Self::Public, _) => StreamAuth::Public,
};
if stream_auth.is_valid(auth_state).await {
Ok(stream_auth)
} else {
Err(HttpError::unauthorized())
}
}
}
pub(crate) enum StreamAuth {
Public,
Private {
session: Box<AuthSession>,
revocation_rx: broadcast::Receiver<AuthRevocation>,
},
}
#[derive(Debug, PartialEq, Eq)]
enum RevocationSignal {
Continue,
Close,
Revalidate,
}
impl StreamAuth {
fn pending_revocation(&mut self) -> RevocationSignal {
let Self::Private {
session,
revocation_rx,
} = self
else {
return RevocationSignal::Continue;
};
loop {
match revocation_rx.try_recv() {
Ok(revocation) if revocation.matches(session) => return RevocationSignal::Close,
Ok(_) => continue,
Err(broadcast::error::TryRecvError::Empty) => return RevocationSignal::Continue,
Err(broadcast::error::TryRecvError::Lagged(_)) => {
*revocation_rx = revocation_rx.resubscribe();
return RevocationSignal::Revalidate;
}
Err(broadcast::error::TryRecvError::Closed) => return RevocationSignal::Close,
}
}
}
pub(crate) async fn is_valid(&mut self, auth_state: &AuthState) -> bool {
loop {
match self.pending_revocation() {
RevocationSignal::Continue => return true,
RevocationSignal::Close => return false,
RevocationSignal::Revalidate => {
if !self.revalidate_session(auth_state).await {
return false;
}
}
}
}
}
pub(crate) async fn next_check<'a>(
&'a mut self,
auth_state: &'a AuthState,
) -> impl Future<Output = bool> + 'a {
let received = match self {
Self::Private { revocation_rx, .. } => revocation_rx.recv().await,
Self::Public => future::pending().await,
};
let signal = match received {
Ok(revocation) if self.matches(&revocation) => {
tracing::debug!("closing private event stream after auth revocation");
RevocationSignal::Close
}
Ok(_) => RevocationSignal::Continue,
Err(broadcast::error::RecvError::Lagged(_)) => {
let Self::Private { revocation_rx, .. } = self else {
unreachable!("public streams never receive revocation signals")
};
*revocation_rx = revocation_rx.resubscribe();
RevocationSignal::Revalidate
}
Err(broadcast::error::RecvError::Closed) => {
tracing::warn!("auth revocation receiver closed; closing private event stream");
RevocationSignal::Close
}
};
async move {
match signal {
RevocationSignal::Continue => true,
RevocationSignal::Close => false,
RevocationSignal::Revalidate => self.revalidate_session(auth_state).await,
}
}
}
fn matches(&self, revocation: &AuthRevocation) -> bool {
match self {
Self::Private { session, .. } => revocation.matches(session),
Self::Public => false,
}
}
async fn revalidate_session(&self, auth_state: &AuthState) -> bool {
let Self::Private { session, .. } = self else {
return true;
};
if let Err(error) = auth_state.validate_private_stream_session(session).await {
tracing::warn!(
?error,
"auth revocation receiver lagged and private stream validation failed"
);
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_context::AppContext;
use crate::client_server::auth::cookie::persistence::{SessionEntity, SessionSecret};
use pubky_common::{capabilities::Capabilities, crypto::Keypair};
fn cookie_session() -> AuthSession {
AuthSession::Cookie(SessionEntity {
id: 1,
secret: SessionSecret::random(),
user_id: 1,
user_pubkey: Keypair::random().public_key(),
capabilities: Capabilities::default(),
created_at: chrono::Utc::now().naive_utc(),
})
}
#[test]
fn buffered_revocation_closes_a_private_stream_before_historical_replay() {
let (sender, receiver) = broadcast::channel(1);
sender.send(AuthRevocation::CookieSession(1)).unwrap();
let mut stream_auth = StreamAuth::Private {
session: Box::new(cookie_session()),
revocation_rx: receiver,
};
assert!(matches!(
stream_auth.pending_revocation(),
RevocationSignal::Close
));
}
#[test]
fn lagged_revocation_receiver_resubscribes_before_revalidation() {
let (sender, receiver) = broadcast::channel(1);
sender.send(AuthRevocation::CookieSession(2)).unwrap();
sender.send(AuthRevocation::CookieSession(1)).unwrap();
let mut stream_auth = StreamAuth::Private {
session: Box::new(cookie_session()),
revocation_rx: receiver,
};
assert!(matches!(
stream_auth.pending_revocation(),
RevocationSignal::Revalidate
));
let StreamAuth::Private { revocation_rx, .. } = &stream_auth else {
unreachable!()
};
assert!(revocation_rx.is_empty());
sender.send(AuthRevocation::CookieSession(1)).unwrap();
assert!(matches!(
stream_auth.pending_revocation(),
RevocationSignal::Close
));
}
#[tokio::test]
#[pubky_test_utils::test]
async fn live_lag_revalidation_is_not_cancelled_by_a_ready_event() {
let context = AppContext::test().await;
let auth_state = AuthState::new(&context);
let (sender, receiver) = broadcast::channel(1);
sender.send(AuthRevocation::CookieSession(1)).unwrap();
sender.send(AuthRevocation::CookieSession(2)).unwrap();
let mut stream_auth = StreamAuth::Private {
session: Box::new(cookie_session()),
revocation_rx: receiver,
};
let mut lock = context.sql_db.pool().begin().await.unwrap();
sqlx::query("LOCK TABLE sessions IN ACCESS EXCLUSIVE MODE")
.execute(&mut *lock)
.await
.unwrap();
let check = tokio::select! {
biased;
check = stream_auth.next_check(&auth_state) => check,
_ = future::ready(()) => panic!("ready event bypassed the lag signal"),
};
lock.rollback().await.unwrap();
assert!(!check.await);
let StreamAuth::Private { revocation_rx, .. } = &stream_auth else {
unreachable!()
};
assert!(revocation_rx.is_empty());
}
}