pub mod rules;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::infra::io::state::DynStateBus;
pub const MAX_REGISTRATIONS_PER_DAY: i64 = 200;
pub const MAX_TICKETS_PER_MINUTE: i64 = 120;
pub const TICKET_TTL: u64 = 10 * 60;
pub const CODE_TTL: u64 = 2 * 60;
pub const USED_CODE_TTL: u64 = 10 * 60;
pub const ACCESS_TTL: u64 = 60 * 60;
pub const REFRESH_FAMILY_TTL: u64 = 30 * 24 * 60 * 60;
pub const CLIENT_TTL: u64 = 90 * 24 * 60 * 60;
const REVOKED_FAMILY_TTL: u64 = 30 * 24 * 60 * 60;
const CIMD_CACHE_MAX: usize = 1_000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Client {
pub client_id: String,
pub client_name: String,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Ticket {
pub route: String,
pub client_id: String,
pub client_name: String,
pub redirect_uri: String,
pub redirect_host: String,
pub challenge: String,
pub resource: String,
pub issuer: String,
pub scope: String,
pub state: Option<String>,
pub credential: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Code {
pub client_id: String,
pub redirect_uri: String,
pub challenge: String,
pub resource: String,
pub issuer: String,
pub scope: String,
pub claims: Value,
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Refresh {
pub family: String,
pub client_id: String,
pub resource: String,
pub issuer: String,
pub scope: String,
pub claims: Value,
pub family_expires_at: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CimdClient {
pub client_name: String,
pub redirect_uris: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct StoreError(pub String);
#[derive(Debug, Clone, PartialEq)]
pub enum Spent<T> {
Fresh(T),
Refused,
}
pub struct PublishedOAuthService {
bus: DynStateBus,
cimd: Mutex<HashMap<String, (Instant, Duration, CimdClient)>>,
}
pub fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
fn key(route: &str, rest: &str) -> String {
format!("zf.oauth/{}/{rest}", rules::route_hash(route))
}
impl PublishedOAuthService {
pub fn new(bus: DynStateBus) -> Self {
Self { bus, cimd: Mutex::new(HashMap::new()) }
}
fn get<T: DeserializeOwned>(&self, owner: &str, project: &str, key: &str) -> Result<Option<T>, StoreError> {
let value = self.bus.durable_get(owner, project, key).map_err(|e| StoreError(e.message))?;
Ok(value.and_then(|v| serde_json::from_value(v).ok()))
}
fn put<T: Serialize>(&self, owner: &str, project: &str, key: &str, value: &T, ttl: u64) -> Result<(), StoreError> {
let value = serde_json::to_value(value).map_err(|e| StoreError(e.to_string()))?;
self.bus.durable_set(owner, project, key, value, Some(ttl.max(1))).map_err(|e| StoreError(e.message))
}
fn exists(&self, owner: &str, project: &str, key: &str) -> Result<bool, StoreError> {
self.bus.durable_exists(owner, project, key).map_err(|e| StoreError(e.message))
}
fn take<T: DeserializeOwned>(&self, owner: &str, project: &str, key: &str) -> Result<Option<T>, StoreError> {
let Some(value) = self.get::<T>(owner, project, key)? else {
return Ok(None);
};
let won = self.bus.durable_del(owner, project, key).map_err(|e| StoreError(e.message))?;
Ok(won.then_some(value))
}
fn count(&self, owner: &str, project: &str, bucket: &str, ttl: u64, cap: i64) -> Result<bool, StoreError> {
let n = self.bus.durable_incr(owner, project, bucket, 1).map_err(|e| StoreError(e.message))?;
if n == 1 {
let _ = self.bus.durable_expire(owner, project, bucket, Some(ttl));
}
Ok(n <= cap)
}
pub fn register_client(&self, owner: &str, project: &str, route: &str, client: &Client) -> Result<bool, StoreError> {
let day = chrono::Utc::now().format("%Y%m%d");
if !self.count(owner, project, &key(route, &format!("rate/register/{day}")), 2 * 86_400, MAX_REGISTRATIONS_PER_DAY)? {
return Ok(false);
}
self.put(owner, project, &key(route, &format!("client/{}", client.client_id)), client, CLIENT_TTL)?;
Ok(true)
}
pub fn client(&self, owner: &str, project: &str, route: &str, client_id: &str) -> Result<Option<Client>, StoreError> {
if !rules::is_issued_client_id(client_id) {
return Ok(None);
}
self.get(owner, project, &key(route, &format!("client/{client_id}")))
}
pub fn renew_client(&self, owner: &str, project: &str, route: &str, client_id: &str) {
if rules::is_issued_client_id(client_id) {
let _ = self.bus.durable_expire(owner, project, &key(route, &format!("client/{client_id}")), Some(CLIENT_TTL));
}
}
pub fn open_ticket(&self, owner: &str, project: &str, ticket: &Ticket) -> Result<Option<String>, StoreError> {
let minute = chrono::Utc::now().format("%Y%m%d%H%M");
if !self.count(owner, project, &key(&ticket.route, &format!("rate/ticket/{minute}")), 120, MAX_TICKETS_PER_MINUTE)? {
return Ok(None);
}
let secret = rules::new_secret();
self.put(owner, project, &format!("zf.oauth/ticket/{}", rules::sha256_hex(&secret)), ticket, TICKET_TTL)?;
Ok(Some(secret))
}
pub fn take_ticket(&self, owner: &str, project: &str, secret: &str) -> Result<Option<Ticket>, StoreError> {
self.take(owner, project, &format!("zf.oauth/ticket/{}", rules::sha256_hex(secret)))
}
pub fn issue_code(&self, owner: &str, project: &str, route: &str, code: &Code) -> Result<String, StoreError> {
let secret = rules::new_secret();
self.put(owner, project, &key(route, &format!("code/{}", rules::sha256_hex(&secret))), code, CODE_TTL)?;
Ok(secret)
}
pub fn take_code(&self, owner: &str, project: &str, route: &str, secret: &str) -> Result<Spent<Code>, StoreError> {
let hash = rules::sha256_hex(secret);
let used = key(route, &format!("code-used/{hash}"));
if let Some(family) = self.get::<String>(owner, project, &used)? {
self.revoke_family(owner, project, route, &family)?;
return Ok(Spent::Refused);
}
let Some(code) = self.take::<Code>(owner, project, &key(route, &format!("code/{hash}")))? else {
return Ok(Spent::Refused);
};
self.put(owner, project, &used, &code.family, USED_CODE_TTL)?;
Ok(Spent::Fresh(code))
}
pub fn issue_refresh(&self, owner: &str, project: &str, route: &str, refresh: &Refresh) -> Result<String, StoreError> {
let secret = rules::new_secret();
let ttl = (refresh.family_expires_at - now_unix()).max(1) as u64;
self.put(owner, project, &key(route, &format!("refresh/{}", rules::sha256_hex(&secret))), refresh, ttl)?;
Ok(secret)
}
pub fn rotate_refresh(&self, owner: &str, project: &str, route: &str, secret: &str) -> Result<Spent<Refresh>, StoreError> {
let hash = rules::sha256_hex(secret);
let used = key(route, &format!("refresh-used/{hash}"));
if let Some(family) = self.get::<String>(owner, project, &used)? {
self.revoke_family(owner, project, route, &family)?;
return Ok(Spent::Refused);
}
let Some(refresh) = self.take::<Refresh>(owner, project, &key(route, &format!("refresh/{hash}")))? else {
return Ok(Spent::Refused);
};
let ttl = (refresh.family_expires_at - now_unix()).max(1) as u64;
self.put(owner, project, &used, &refresh.family, ttl)?;
if self.family_revoked(owner, project, route, &refresh.family)? || refresh.family_expires_at <= now_unix() {
return Ok(Spent::Refused);
}
Ok(Spent::Fresh(refresh))
}
pub fn revoke_family(&self, owner: &str, project: &str, route: &str, family: &str) -> Result<(), StoreError> {
self.put(owner, project, &key(route, &format!("family-revoked/{family}")), &true, REVOKED_FAMILY_TTL)
}
pub fn family_revoked(&self, owner: &str, project: &str, route: &str, family: &str) -> Result<bool, StoreError> {
self.exists(owner, project, &key(route, &format!("family-revoked/{family}")))
}
pub fn cached_cimd(&self, client_id: &str) -> Option<CimdClient> {
let cache = self.cimd.lock().unwrap_or_else(|e| e.into_inner());
cache.get(client_id).filter(|(at, ttl, _)| at.elapsed() < *ttl).map(|(_, _, c)| c.clone())
}
pub fn cache_cimd(&self, client_id: &str, client: CimdClient, ttl: Duration) {
let mut cache = self.cimd.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= CIMD_CACHE_MAX {
cache.retain(|_, (at, ttl, _)| at.elapsed() < *ttl);
if cache.len() >= CIMD_CACHE_MAX {
cache.clear();
}
}
cache.insert(client_id.to_string(), (Instant::now(), ttl, client));
}
}
#[cfg(test)]
mod tests;