aauth 0.0.1

Rust implementation of the AAuth authorization protocol
Documentation
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::headers::{AAuthRequirementParams, build_aauth_requirement};
use crate::interaction_code::generate_code;
use crate::types::{RequirementLevel, TokenResponseBody};

#[derive(Debug, Clone)]
pub struct PendingRequest {
    pub id: String,
    pub code: String,
    pub created_at: u64,
    pub result: Arc<Mutex<Option<Result<TokenResponseBody, String>>>>,
}

#[derive(Debug, Clone)]
pub struct InteractionManagerOptions {
    pub base_url: String,
    pub interaction_url: String,
    pub pending_path: Option<String>,
    pub ttl: Option<u64>,
}

pub struct InteractionManager {
    pending: Mutex<HashMap<String, PendingRequest>>,
    base_url: String,
    interaction_url: String,
    pending_path: String,
    ttl: u64,
}

impl InteractionManager {
    pub fn new(options: InteractionManagerOptions) -> Self {
        Self {
            pending: Mutex::new(HashMap::new()),
            base_url: options.base_url.trim_end_matches('/').to_string(),
            interaction_url: options.interaction_url.trim_end_matches('/').to_string(),
            pending_path: options
                .pending_path
                .unwrap_or_else(|| "/pending".to_string()),
            ttl: options.ttl.unwrap_or(600),
        }
    }

    pub fn create_pending(&self) -> (HashMap<String, String>, PendingRequest) {
        let id: String = (0..16)
            .map(|_| format!("{:02x}", rand::random::<u8>()))
            .collect();
        let code = generate_code();
        let created_at = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();

        let pending = PendingRequest {
            id: id.clone(),
            code: code.clone(),
            created_at,
            result: Arc::new(Mutex::new(None)),
        };

        self.pending
            .lock()
            .unwrap()
            .insert(id.clone(), pending.clone());

        let location = format!("{}{}/{}", self.base_url, self.pending_path, id);
        let aauth_requirement = build_aauth_requirement(
            RequirementLevel::Interaction,
            Some(&AAuthRequirementParams {
                url: Some(&self.interaction_url),
                code: Some(&code),
                ..Default::default()
            }),
        )
        .expect("valid interaction header");

        let headers = HashMap::from([
            ("Location".to_string(), location),
            ("Retry-After".to_string(), "0".to_string()),
            ("Cache-Control".to_string(), "no-store".to_string()),
            ("AAuth-Requirement".to_string(), aauth_requirement),
        ]);

        (headers, pending)
    }

    pub fn get_pending(&self, id: &str) -> Option<PendingRequest> {
        self.pending.lock().unwrap().get(id).cloned()
    }

    pub fn resolve(&self, id: &str, value: TokenResponseBody) -> Result<(), String> {
        let pending = self
            .pending
            .lock()
            .unwrap()
            .get(id)
            .cloned()
            .ok_or_else(|| format!("No pending request with id: {id}"))?;
        *pending.result.lock().unwrap() = Some(Ok(value));
        Ok(())
    }

    pub fn reject(&self, id: &str, error: impl Into<String>) -> Result<(), String> {
        let pending = self
            .pending
            .lock()
            .unwrap()
            .get(id)
            .cloned()
            .ok_or_else(|| format!("No pending request with id: {id}"))?;
        *pending.result.lock().unwrap() = Some(Err(error.into()));
        Ok(())
    }

    pub fn remove(&self, id: &str) {
        self.pending.lock().unwrap().remove(id);
    }

    pub fn cleanup(&self) {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let ttl = self.ttl;
        self.pending.lock().unwrap().retain(|_, pending| {
            if now.saturating_sub(pending.created_at) > ttl {
                *pending.result.lock().unwrap() = Some(Err("Pending request expired".into()));
                false
            } else {
                true
            }
        });
    }

    pub fn size(&self) -> usize {
        self.pending.lock().unwrap().len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn create_pending_header_format() {
        let manager = InteractionManager::new(InteractionManagerOptions {
            base_url: "https://auth.example".into(),
            interaction_url: "https://auth.example/interact".into(),
            pending_path: None,
            ttl: None,
        });
        let (headers, pending) = manager.create_pending();
        assert!(headers["Location"].contains("/pending/"));
        assert!(headers["AAuth-Requirement"].contains("requirement=interaction"));
        assert!(pending.code.contains('-'));
    }
}