#![allow(clippy::module_inception)]
pub mod cache;
pub mod config;
pub mod proof;
pub mod providers;
pub mod server;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub use cache::ProofCache;
pub use config::{IdentityConfig, ProviderConfig, ProviderKind};
pub use proof::{Proof, ProofSigner};
pub use providers::{idme::IdMeProvider, mock::MockProvider};
pub use server::{CallbackServer, Inflight, ServerHandle};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requirement {
pub provider: String,
pub scope: String,
pub allowed_subjects: Vec<String>,
#[serde(default = "default_max_age")]
pub max_proof_age_seconds: u64,
#[serde(default)]
pub loa: u8,
}
fn default_max_age() -> u64 {
900 }
impl Requirement {
pub fn allows(&self, vi: &VerifiedIdentity) -> bool {
let want: HashSet<&str> = self.allowed_subjects.iter().map(String::as_str).collect();
if want.contains("*") {
return true;
}
let prefixed = format!("{}|{}", vi.provider, vi.subject);
if want.contains(prefixed.as_str()) {
return true;
}
if want.contains(vi.subject.as_str()) {
return true;
}
if let Some(email) = vi.email.as_deref() {
if want.contains(email) {
return true;
}
}
false
}
pub fn is_satisfied_by(&self, proof: &Proof, now_secs: u64) -> bool {
if proof.provider != self.provider {
return false;
}
if proof.scope != self.scope {
return false;
}
if proof.loa < self.loa {
return false;
}
if proof.expires_at <= now_secs {
return false;
}
if now_secs.saturating_sub(proof.verified_at) > self.max_proof_age_seconds {
return false;
}
let vi = VerifiedIdentity {
provider: proof.provider.clone(),
subject: proof.subject.clone(),
email: proof.email.clone(),
loa: proof.loa,
raw: serde_json::Value::Null,
};
self.allows(&vi)
}
}
#[derive(Debug, Clone)]
pub struct ChallengeRequest {
pub rule_id: String,
pub requirement: Requirement,
pub callback_url: String,
pub challenge_id: String,
}
#[derive(Debug, Clone)]
pub struct Challenge {
pub challenge_id: String,
pub verify_url: String,
pub pkce_verifier: Option<String>,
pub nonce: String,
pub expires_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedIdentity {
pub provider: String,
pub subject: String,
pub email: Option<String>,
pub loa: u8,
#[serde(skip_serializing_if = "serde_json::Value::is_null", default)]
pub raw: serde_json::Value,
}
#[async_trait]
pub trait IdentityProvider: Send + Sync + 'static {
fn id(&self) -> &str;
fn is_ready(&self) -> bool;
async fn begin(&self, req: ChallengeRequest) -> anyhow::Result<Challenge>;
async fn exchange(
&self,
challenge_id: &str,
code: &str,
state: &str,
pkce_verifier: Option<&str>,
) -> anyhow::Result<VerifiedIdentity>;
}
pub struct IdentityGate {
config: IdentityConfig,
providers: Vec<Arc<dyn IdentityProvider>>,
cache: Arc<ProofCache>,
signer: Arc<ProofSigner>,
server: tokio::sync::OnceCell<ServerHandle>,
}
impl IdentityGate {
pub fn new(
config: IdentityConfig,
providers: Vec<Arc<dyn IdentityProvider>>,
state_dir: std::path::PathBuf,
) -> anyhow::Result<Self> {
let signer = Arc::new(ProofSigner::load_or_create(&state_dir)?);
let cache = Arc::new(ProofCache::open(state_dir.join("identity-cache.json"), &signer)?);
Ok(Self {
config,
providers,
cache,
signer,
server: tokio::sync::OnceCell::new(),
})
}
pub fn cached_proof_for(&self, req: &Requirement) -> Option<Proof> {
let now = unix_now();
self.cache.find_satisfying(req, now)
}
pub fn provider(&self, id: &str) -> Option<Arc<dyn IdentityProvider>> {
self.providers.iter().find(|p| p.id() == id).cloned()
}
async fn ensure_server(&self) -> anyhow::Result<&ServerHandle> {
self.server
.get_or_try_init(|| async {
CallbackServer::spawn(
&self.config.callback_host,
self.config.callback_port,
self.providers.clone(),
self.cache.clone(),
self.signer.clone(),
)
.await
})
.await
}
pub async fn callback_base(&self) -> anyhow::Result<String> {
let h = self.ensure_server().await?;
Ok(h.base_url())
}
pub async fn register_inflight(
&self,
challenge: &Challenge,
requirement: Requirement,
provider: String,
rule_id: String,
) -> anyhow::Result<()> {
let h = self.ensure_server().await?;
h.register(
challenge.clone(),
server::Inflight { rule_id, provider, requirement },
)
.await;
Ok(())
}
pub async fn wait_for_proof(
&self,
req: &Requirement,
hold_seconds: u64,
) -> Option<Proof> {
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(hold_seconds);
loop {
if let Some(p) = self.cached_proof_for(req) {
return Some(p);
}
if std::time::Instant::now() >= deadline {
return None;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
pub fn mint_and_cache(
&self,
vi: &VerifiedIdentity,
req: &Requirement,
) -> anyhow::Result<Proof> {
let now = unix_now();
let proof = Proof {
v: 1,
provider: vi.provider.clone(),
subject: vi.subject.clone(),
email: vi.email.clone(),
loa: vi.loa,
scope: req.scope.clone(),
verified_at: now,
expires_at: now.saturating_add(req.max_proof_age_seconds),
nonce: hex::encode(rand::random::<[u8; 16]>()),
sig: String::new(),
};
let signed = self.signer.sign(proof)?;
self.cache.insert(signed.clone())?;
Ok(signed)
}
pub fn cached_count(&self) -> usize {
self.cache.count_valid(unix_now())
}
pub fn flush(&self) -> anyhow::Result<usize> {
self.cache.flush()
}
pub fn hold_seconds(&self) -> u64 {
self.config.hold_seconds
}
pub fn has_ready_provider(&self) -> bool {
self.providers.iter().any(|p| p.is_ready())
}
pub fn config(&self) -> &IdentityConfig {
&self.config
}
}
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn vi(provider: &str, subject: &str, email: Option<&str>, loa: u8) -> VerifiedIdentity {
VerifiedIdentity {
provider: provider.into(),
subject: subject.into(),
email: email.map(str::to_string),
loa,
raw: serde_json::Value::Null,
}
}
#[test]
fn allow_list_matches_email() {
let allowed = format!("{}@{}", "ace", "aperion.ai");
let denied = format!("{}@{}", "bee", "other.com");
let req = Requirement {
provider: "id_me".into(),
scope: "scm.commit".into(),
allowed_subjects: vec![allowed.clone()],
max_proof_age_seconds: 900,
loa: 2,
};
assert!(req.allows(&vi("id_me", "sub-1", Some(allowed.as_str()), 2)));
assert!(!req.allows(&vi("id_me", "sub-2", Some(denied.as_str()), 2)));
assert!(!req.allows(&vi("id_me", "sub-3", None, 2)));
}
#[test]
fn allow_list_matches_subject_with_prefix() {
let req = Requirement {
provider: "id_me".into(),
scope: "x".into(),
allowed_subjects: vec!["id_me|sub-1".into()],
max_proof_age_seconds: 900,
loa: 0,
};
assert!(req.allows(&vi("id_me", "sub-1", None, 0)));
}
#[test]
fn wildcard_lets_anyone_pass() {
let req = Requirement {
provider: "id_me".into(),
scope: "x".into(),
allowed_subjects: vec!["*".into()],
max_proof_age_seconds: 900,
loa: 0,
};
assert!(req.allows(&vi("id_me", "sub-99", Some("[email protected]"), 0)));
}
#[test]
fn requirement_freshness_check() {
let req = Requirement {
provider: "id_me".into(),
scope: "x".into(),
allowed_subjects: vec!["*".into()],
max_proof_age_seconds: 60,
loa: 0,
};
let now = unix_now();
let fresh = Proof {
v: 1, provider: "id_me".into(), subject: "s".into(), email: None,
loa: 0, scope: "x".into(), verified_at: now, expires_at: now + 60,
nonce: "".into(), sig: "".into(),
};
let stale = Proof { verified_at: now - 120, expires_at: now + 60, ..fresh.clone() };
let wrong_scope = Proof { scope: "y".into(), ..fresh.clone() };
let low_loa = Proof { loa: 0, ..fresh.clone() };
let high_loa_req = Requirement { loa: 2, ..req.clone() };
assert!(req.is_satisfied_by(&fresh, now));
assert!(!req.is_satisfied_by(&stale, now));
assert!(!req.is_satisfied_by(&wrong_scope, now));
assert!(!high_loa_req.is_satisfied_by(&low_loa, now));
}
}