barbed 0.0.4

Twitch Helix, EventSub, OAuth, IRC, and provider helpers
Documentation
use std::error::Error as StdError;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;

use crate::oauth::TwitchAuthOutcome;

pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
pub type StoreResult<T> = Result<T, BoxError>;

#[async_trait]
pub trait TwitchAuthStore: Send + Sync {
    async fn load_auth(&self) -> StoreResult<Option<TwitchAuthOutcome>>;

    async fn save_auth(&self, auth: &TwitchAuthOutcome) -> StoreResult<()>;

    async fn clear_auth(&self) -> StoreResult<()>;
}

#[derive(Clone, Default)]
pub struct InMemoryTwitchAuthStore {
    state: Arc<Mutex<Option<TwitchAuthOutcome>>>,
}

impl InMemoryTwitchAuthStore {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from_auth(auth: TwitchAuthOutcome) -> Self {
        Self {
            state: Arc::new(Mutex::new(Some(auth))),
        }
    }
}

#[async_trait]
impl TwitchAuthStore for InMemoryTwitchAuthStore {
    async fn load_auth(&self) -> StoreResult<Option<TwitchAuthOutcome>> {
        Ok(self
            .state
            .lock()
            .expect("in-memory auth store lock poisoned")
            .clone())
    }

    async fn save_auth(&self, auth: &TwitchAuthOutcome) -> StoreResult<()> {
        *self
            .state
            .lock()
            .expect("in-memory auth store lock poisoned") = Some(auth.clone());
        Ok(())
    }

    async fn clear_auth(&self) -> StoreResult<()> {
        *self
            .state
            .lock()
            .expect("in-memory auth store lock poisoned") = None;
        Ok(())
    }
}

#[cfg(feature = "reqwest-client")]
fn store_err(context: &'static str, err: BoxError) -> anyhow::Error {
    anyhow::anyhow!("{context}: {err}")
}

#[cfg(feature = "reqwest-client")]
async fn load_stored_auth<S: TwitchAuthStore>(
    store: &S,
) -> anyhow::Result<Option<TwitchAuthOutcome>> {
    store
        .load_auth()
        .await
        .map_err(|err| store_err("failed to load stored auth", err))
}

#[cfg(feature = "reqwest-client")]
async fn refresh_and_persist<S: TwitchAuthStore>(
    http: &reqwest::Client,
    store: &S,
    client_id: &str,
    mut auth: TwitchAuthOutcome,
) -> anyhow::Result<Option<TwitchAuthOutcome>> {
    let Some(refreshed) =
        crate::native::refresh_access_token(http, client_id, &auth.tokens.refresh_token).await?
    else {
        store
            .clear_auth()
            .await
            .map_err(|err| store_err("failed to clear rejected auth", err))?;
        return Ok(None);
    };

    auth.tokens = refreshed;
    store
        .save_auth(&auth)
        .await
        .map_err(|err| store_err("failed to persist refreshed auth", err))?;
    Ok(Some(auth))
}

#[cfg(feature = "reqwest-client")]
pub async fn ensure_valid_stored_auth<S: TwitchAuthStore>(
    http: &reqwest::Client,
    store: &S,
    client_id: &str,
) -> anyhow::Result<Option<TwitchAuthOutcome>> {
    let Some(auth) = load_stored_auth(store).await? else {
        return Ok(None);
    };

    if crate::native::validate_access_token(http, &auth.tokens.access_token)
        .await?
        .is_some()
    {
        return Ok(Some(auth));
    }

    refresh_and_persist(http, store, client_id, auth).await
}

#[cfg(feature = "reqwest-client")]
pub async fn refresh_stored_auth<S: TwitchAuthStore>(
    http: &reqwest::Client,
    store: &S,
    client_id: &str,
) -> anyhow::Result<Option<TwitchAuthOutcome>> {
    let Some(auth) = load_stored_auth(store).await? else {
        return Ok(None);
    };

    refresh_and_persist(http, store, client_id, auth).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::TwitchIdentity;
    use crate::oauth::{TwitchAuthOutcome, TwitchTokenState};

    fn sample_auth() -> TwitchAuthOutcome {
        TwitchAuthOutcome {
            identity: TwitchIdentity::new("42", "viewer", "Viewer"),
            tokens: TwitchTokenState {
                access_token: "access".to_string(),
                refresh_token: "refresh".to_string(),
                expires_in_seconds: Some(3600),
                scope: vec!["chat:read".to_string()],
                token_type: "bearer".to_string(),
                linked_at_ms: 1_700_000_000_000,
            },
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn in_memory_auth_store_round_trips() {
        let store = InMemoryTwitchAuthStore::new();
        assert!(
            store
                .load_auth()
                .await
                .expect("load should succeed")
                .is_none()
        );

        let auth = sample_auth();
        store.save_auth(&auth).await.expect("save should succeed");
        assert_eq!(
            store.load_auth().await.expect("load should succeed"),
            Some(auth.clone())
        );

        store.clear_auth().await.expect("clear should succeed");
        assert!(
            store
                .load_auth()
                .await
                .expect("load should succeed")
                .is_none()
        );
    }
}