mod account;
mod factor_pipeline;
mod login;
pub mod outcomes;
#[cfg(feature = "device")]
pub mod step_up;
mod verification;
#[cfg(feature = "fido2")]
pub(crate) mod fido2_service;
#[cfg(feature = "ldap")]
mod ldap_service;
#[cfg(feature = "oauth")]
pub(crate) mod oauth_service;
pub mod session_validator;
pub use outcomes::{FactorOutcome, LoginOutcome, PrepareOutcome, SignupOutcome};
pub use session_validator::{NoSessionRegistryError, SessionValidator, require_valid_session};
#[cfg(feature = "device")]
pub use step_up::{StepUpPolicy, StepUpPolicyBuilder, decide_step_up};
use crate::{
authn::store::{FactorStore, IdentityStore},
session::store::{SessionRegistry, SessionRegistryAdapter, SessionRegistryHandle},
};
use axess_clock::{Clock, SystemClock};
use axess_rng::{SecureRng, SystemRng};
use std::sync::Arc;
pub struct AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore,
{
pub(crate) inner: Arc<AuthnServiceBuilder<I, F>>,
}
impl<I, F> Clone for AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore,
{
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
pub struct RequestAuthnService<I, F>
where
I: IdentityStore,
F: FactorStore,
{
service: AuthnService<I, F>,
pub(crate) audit: Arc<crate::authn::event::AuditContext>,
}
impl<I, F> Clone for RequestAuthnService<I, F>
where
I: IdentityStore,
F: FactorStore,
{
fn clone(&self) -> Self {
Self {
service: self.service.clone(),
audit: Arc::clone(&self.audit),
}
}
}
impl<I, F> std::ops::Deref for RequestAuthnService<I, F>
where
I: IdentityStore,
F: FactorStore,
{
type Target = AuthnService<I, F>;
fn deref(&self) -> &Self::Target {
&self.service
}
}
pub struct AuthnServiceBuilder<I, F>
where
I: IdentityStore,
F: FactorStore,
{
pub(crate) identity: Arc<I>,
pub(crate) factors: Arc<F>,
pub(crate) registry: Option<Arc<dyn SessionRegistryHandle>>,
pub(crate) metrics: Arc<dyn crate::metrics::AuthnMetrics>,
pub(crate) max_sessions_per_user: Option<usize>,
pub(crate) rng: Arc<dyn SecureRng>,
pub(crate) clock: Arc<dyn Clock>,
#[cfg(feature = "fido2")]
pub(crate) fido2: Option<Arc<dyn axess_factors::fido2::Fido2Provider>>,
#[cfg(feature = "fido2")]
pub(crate) fido2_options: crate::authn::factor::Fido2Options,
#[cfg(feature = "ldap")]
pub(crate) ldap: Option<Arc<dyn axess_factors::ldap::LdapProvider>>,
#[cfg(feature = "oauth")]
pub(crate) oauth_providers: crate::federation::oauth::OAuthProviderRegistry,
#[cfg(feature = "oauth")]
pub(crate) sid_map: crate::federation::backchannel_logout::SidMap,
#[cfg(feature = "oauth")]
pub(crate) sid_map_capacity: usize,
}
#[cfg(feature = "oauth")]
pub const DEFAULT_SID_MAP_CAPACITY: usize = 10_000;
impl<I, F> AuthnServiceBuilder<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
pub fn new(identity: I, factors: F) -> Self {
Self {
identity: Arc::new(identity),
factors: Arc::new(factors),
registry: None,
metrics: Arc::new(crate::metrics::NoopMetrics),
max_sessions_per_user: None,
rng: Arc::new(SystemRng),
clock: Arc::new(SystemClock),
#[cfg(feature = "fido2")]
fido2: None,
#[cfg(feature = "fido2")]
fido2_options: Default::default(),
#[cfg(feature = "ldap")]
ldap: None,
#[cfg(feature = "oauth")]
oauth_providers: Default::default(),
#[cfg(feature = "oauth")]
sid_map: Default::default(),
#[cfg(feature = "oauth")]
sid_map_capacity: DEFAULT_SID_MAP_CAPACITY,
}
}
}
impl<B> AuthnService<B, B>
where
B: crate::authn::store::AuthnBackend + Clone,
{
pub fn from_backend(backend: B) -> Self {
Self::new(backend.clone(), backend)
}
pub fn builder_from_backend(backend: B) -> AuthnServiceBuilder<B, B> {
AuthnService::builder(backend.clone(), backend)
}
}
impl<I, F> AuthnService<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
pub fn new(identity: I, factors: F) -> Self {
Self::builder(identity, factors).build()
}
pub fn builder(identity: I, factors: F) -> AuthnServiceBuilder<I, F> {
AuthnServiceBuilder::new(identity, factors)
}
pub fn with_audit_context(
&self,
ctx: crate::authn::event::AuditContext,
) -> RequestAuthnService<I, F> {
RequestAuthnService {
service: self.clone(),
audit: Arc::new(ctx),
}
}
#[cfg(feature = "oauth")]
pub fn oauth_providers(&self) -> &crate::federation::oauth::OAuthProviderRegistry {
&self.inner.oauth_providers
}
#[cfg(feature = "oauth")]
pub fn has_oauth_providers(&self) -> bool {
self.inner.oauth_providers.provider_count() > 0
}
#[cfg(feature = "fido2")]
pub fn has_fido2(&self) -> bool {
self.inner.fido2.is_some()
}
#[cfg(feature = "ldap")]
pub fn has_ldap(&self) -> bool {
self.inner.ldap.is_some()
}
pub fn has_session_registry(&self) -> bool {
self.inner.registry.is_some()
}
pub async fn invalidate_user_sessions(
&self,
user_id: &crate::authn::ids::UserId,
) -> Result<(), NoSessionRegistryError> {
match &self.inner.registry {
Some(reg) => {
reg.invalidate_user(user_id).await;
Ok(())
}
None => Err(NoSessionRegistryError),
}
}
pub async fn invalidate_session(
&self,
user_id: &crate::authn::ids::UserId,
session_id: &crate::session::id::SessionId,
) -> Result<(), NoSessionRegistryError> {
match &self.inner.registry {
Some(reg) => {
reg.invalidate_session(user_id, session_id).await;
Ok(())
}
None => Err(NoSessionRegistryError),
}
}
pub async fn active_sessions(
&self,
user_id: &crate::authn::ids::UserId,
) -> Result<Vec<crate::session::id::SessionId>, NoSessionRegistryError> {
match &self.inner.registry {
Some(reg) => Ok(reg.active_sessions(user_id).await),
None => Err(NoSessionRegistryError),
}
}
pub fn session_validator(&self) -> SessionValidator {
SessionValidator {
registry: self.inner.registry.clone(),
identity: None,
}
}
pub fn session_validator_with_identity_check(&self) -> SessionValidator {
SessionValidator {
registry: self.inner.registry.clone(),
identity: Some(Arc::new(session_validator::IdentityWrapper(
self.inner.identity.clone(),
))),
}
}
}
impl<I, F> RequestAuthnService<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
pub(crate) async fn emit_audit(
&self,
builder: crate::authn::event::AuthEventBuilder,
) -> Result<(), crate::authn::error::AuthnError<I::Error>> {
self.emit_audit_at(builder, self.inner.clock.now()).await
}
pub(crate) async fn emit_audit_at(
&self,
builder: crate::authn::event::AuthEventBuilder,
event_time: chrono::DateTime<chrono::Utc>,
) -> Result<(), crate::authn::error::AuthnError<I::Error>> {
let event = builder.with_audit_context(&self.audit).build_at(event_time);
let event_type = format!("{:?}", event.event_type);
let event_status = format!("{:?}", event.event_status);
match self.inner.identity.record_event(event).await {
Ok(crate::authn::store::AuditOutcome::Recorded) => Ok(()),
Ok(crate::authn::store::AuditOutcome::Shed) => {
self.inner.metrics.audit_event_shed();
Ok(())
}
Err(e) => {
tracing::error!(
error = %e,
event_type = %event_type,
event_status = %event_status,
"identity store rejected audit event; failing the flow rather \
than proceeding unrecorded"
);
self.inner.metrics.audit_store_outage();
Err(crate::authn::error::AuthnError::Store(e))
}
}
}
}
impl<I, F> AuthnServiceBuilder<I, F>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
pub fn with_rng(mut self, rng: impl SecureRng) -> Self {
self.rng = Arc::new(rng);
self
}
pub fn with_clock(mut self, clock: impl Clock) -> Self {
self.clock = Arc::new(clock);
self
}
#[cfg(feature = "fido2")]
pub fn with_fido2(mut self, provider: impl axess_factors::fido2::Fido2Provider) -> Self {
self.fido2 = Some(Arc::new(provider));
self
}
#[cfg(feature = "fido2")]
pub fn with_fido2_options(mut self, options: crate::authn::factor::Fido2Options) -> Self {
self.fido2_options = options;
self
}
#[cfg(feature = "ldap")]
pub fn with_ldap(mut self, provider: impl axess_factors::ldap::LdapProvider) -> Self {
self.ldap = Some(Arc::new(provider));
self
}
#[cfg(feature = "oauth")]
pub fn with_oauth_provider(
mut self,
provider: impl axess_factors::oauth::OAuthProvider,
) -> Self {
self.oauth_providers.add(provider);
self
}
#[cfg(feature = "oauth")]
pub fn with_sid_map_capacity(mut self, capacity: usize) -> Self {
self.sid_map_capacity = capacity;
self
}
pub fn with_registry(mut self, registry: impl SessionRegistry + 'static) -> Self {
self.registry = Some(Arc::new(SessionRegistryAdapter(registry)));
self
}
pub fn with_max_sessions_per_user(mut self, max: usize) -> Self {
self.max_sessions_per_user = Some(max);
self
}
pub fn with_metrics(mut self, metrics: impl crate::metrics::AuthnMetrics) -> Self {
self.metrics = Arc::new(metrics);
self
}
pub fn build(self) -> AuthnService<I, F> {
AuthnService {
inner: Arc::new(self),
}
}
}
#[cfg(test)]
mod audit_context_wiring_tests {
use super::AuthnService;
use crate::authn::event::{AuditContext, AuthEventBuilder, AuthEventType};
use crate::testing::{MockFactorStore, MockIdentityStore};
fn ctx_with_ip(ip: &str) -> AuditContext {
AuditContext {
ip_address: Some(ip.parse().expect("test literal is a valid address")),
user_agent: Some("probe/1.0".to_owned()),
..Default::default()
}
}
#[tokio::test]
async fn context_reaches_the_stored_event() {
let identity = MockIdentityStore::new();
let service = AuthnService::new(identity.clone(), MockFactorStore::new());
service
.with_audit_context(ctx_with_ip("203.0.113.7"))
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect("emit must succeed");
let events = identity.events();
let event = events.last().expect("one event recorded");
assert_eq!(
event.ip_address,
Some("203.0.113.7".parse().unwrap()),
"the context's address must reach the stored event"
);
assert_eq!(event.user_agent.as_deref(), Some("probe/1.0"));
}
#[tokio::test]
async fn an_honestly_empty_context_still_records() {
let identity = MockIdentityStore::new();
let service = AuthnService::new(identity.clone(), MockFactorStore::new());
let empty = AuditContext::default();
assert!(empty.ip_address.is_none());
service
.with_audit_context(empty)
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect("an empty context is still a context");
let events = identity.events();
assert_eq!(events.len(), 1);
assert!(events[0].ip_address.is_none());
assert_eq!(
events[0].ip_source,
crate::client_ip::Source::Unknown,
"the row says it does not know, rather than claiming a source"
);
}
#[tokio::test]
async fn request_handles_share_the_same_collaborators() {
let identity = MockIdentityStore::new();
let service = AuthnService::new(identity.clone(), MockFactorStore::new());
service
.with_audit_context(ctx_with_ip("203.0.113.7"))
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect("emit");
service
.with_audit_context(ctx_with_ip("198.51.100.4"))
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect("emit");
let events = identity.events();
assert_eq!(events.len(), 2, "both handles wrote to the one store");
assert_ne!(
events[0].ip_address, events[1].ip_address,
"each handle carried its own context"
);
}
#[tokio::test]
async fn the_request_handle_derefs_to_the_service() {
let service = AuthnService::new(MockIdentityStore::new(), MockFactorStore::new());
let request = service.with_audit_context(AuditContext::default());
assert!(!request.has_session_registry());
}
}
#[cfg(test)]
mod audit_shed_tests {
use super::AuthnService;
use crate::authn::event::{AuditContext, AuthEventBuilder, AuthEventType};
use crate::authn::store::AuditOutcome;
use crate::testing::{MockFactorStore, MockIdentityStore};
#[tokio::test]
async fn shedding_continues_the_flow_where_an_outage_fails_it() {
let identity = MockIdentityStore::new();
let service = AuthnService::new(identity.clone(), MockFactorStore::new())
.with_audit_context(AuditContext::default());
identity.arm_record_event_shedding();
service
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect("a deliberate drop must not fail the flow");
assert!(
identity.events().is_empty(),
"shed means nothing was stored"
);
identity.disarm_record_event_shedding();
identity.arm_record_event_failure();
service
.emit_audit(AuthEventBuilder::success(AuthEventType::Authenticated))
.await
.expect_err("an outage must still fail the flow");
}
#[tokio::test]
async fn a_discarding_sink_reports_shed_not_recorded() {
use crate::authn::store::IdentityAuthnLog;
let backend = crate::authn::store::NoopAuthnLog(MockIdentityStore::new());
let outcome = backend
.record_event(AuthEventBuilder::success(AuthEventType::Authenticated).build())
.await
.expect("noop sink never errors");
assert_eq!(
outcome,
AuditOutcome::Shed,
"a sink that discards must not report Recorded: that claims a \
write which never happened, which is what made the audit gap \
invisible in the first place"
);
}
#[tokio::test]
async fn a_writing_sink_reports_recorded() {
use crate::authn::store::IdentityAuthnLog;
let identity = MockIdentityStore::new();
let outcome = identity
.record_event(AuthEventBuilder::success(AuthEventType::Authenticated).build())
.await
.expect("mock sink never errors");
assert_eq!(outcome, AuditOutcome::Recorded);
assert_eq!(identity.events().len(), 1, "and actually wrote it");
}
}