rvoip_auth_core/providers.rs
1//! Auth provider contracts for RVoIP services.
2//!
3//! Protocol crates use these traits to authenticate credentials without
4//! depending on a specific user database or identity provider. `users-core`
5//! implements these traits behind its `auth-core` feature, while applications
6//! can implement them for external services such as LDAP, OIDC, IMS, or a
7//! custom database.
8
9use std::collections::BTreeMap;
10use std::time::{Duration, SystemTime};
11
12use async_trait::async_trait;
13use rvoip_core_traits::identity::IdentityAssurance;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::sip_digest::DigestAlgorithm;
18
19/// Error returned by provider-backed credential checks.
20#[derive(Debug, Error)]
21pub enum CredentialAuthError {
22 /// Credentials were present but did not authenticate.
23 #[error("invalid credentials")]
24 Invalid,
25
26 /// The backing provider could not answer the request.
27 #[error("credential provider unavailable: {0}")]
28 Unavailable(String),
29
30 /// A configured security policy rejected the credential or request.
31 #[error("credential policy rejected request: {0}")]
32 PolicyRejected(String),
33}
34
35/// Password verifier for Basic-style username/password authentication.
36///
37/// This trait intentionally verifies credentials without issuing access or
38/// refresh tokens. Token issuance remains a user-service concern.
39#[async_trait]
40pub trait PasswordVerifier: Send + Sync {
41 /// Verify a username/password pair and return the authenticated identity.
42 async fn verify_password(
43 &self,
44 username: &str,
45 password: &str,
46 ) -> Result<IdentityAssurance, CredentialAuthError>;
47}
48
49/// Secret material usable for SIP Digest validation.
50#[derive(Debug, Clone, Eq, PartialEq)]
51pub enum DigestSecret {
52 /// Plaintext SIP Digest password.
53 PlaintextPassword(String),
54
55 /// Precomputed HA1 value for `username:realm:password`.
56 ///
57 /// For `-sess` algorithms this is the base HA1 before nonce/cnonce folding.
58 Ha1(String),
59}
60
61/// Provider for SIP Digest credential material.
62///
63/// Implementations should prefer returning [`DigestSecret::Ha1`] so the
64/// backing store does not retain plaintext SIP secrets. This is separate from
65/// login password storage; Argon2 login hashes are not valid SIP Digest
66/// secrets.
67#[async_trait]
68pub trait DigestSecretProvider: Send + Sync {
69 /// Look up SIP Digest credential material for a username and realm.
70 async fn lookup_digest_secret(
71 &self,
72 username: &str,
73 realm: &str,
74 algorithm: DigestAlgorithm,
75 ) -> Result<Option<DigestSecret>, CredentialAuthError>;
76}
77
78/// API key verifier for services that accept first-party API keys directly.
79#[async_trait]
80pub trait ApiKeyVerifier: Send + Sync {
81 /// Verify an API key and return the authenticated identity.
82 async fn verify_api_key(&self, api_key: &str)
83 -> Result<IdentityAssurance, CredentialAuthError>;
84}
85
86/// Revocation state for an access token identifier.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum TokenRevocationStatus {
89 /// Token identifier is not revoked.
90 Active,
91 /// Token identifier has been revoked and must be rejected.
92 Revoked,
93}
94
95/// Redacted context supplied to a token revocation checker.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct TokenRevocationContext {
98 /// Token identifier, usually the JWT `jti` claim.
99 pub token_id: String,
100 /// Token subject, usually the JWT `sub` claim.
101 pub subject: Option<String>,
102 /// Token issuer, usually the JWT `iss` claim.
103 pub issuer: Option<String>,
104 /// Token issued-at time when present.
105 pub issued_at: Option<SystemTime>,
106 /// Token expiry time when present.
107 pub expires_at: Option<SystemTime>,
108}
109
110impl TokenRevocationContext {
111 /// Create a revocation context for a token identifier.
112 pub fn new(token_id: impl Into<String>) -> Self {
113 Self {
114 token_id: token_id.into(),
115 subject: None,
116 issuer: None,
117 issued_at: None,
118 expires_at: None,
119 }
120 }
121
122 /// Attach a token subject.
123 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
124 self.subject = Some(subject.into());
125 self
126 }
127
128 /// Attach a token issuer.
129 pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
130 self.issuer = Some(issuer.into());
131 self
132 }
133
134 /// Attach token issued-at and expiry times.
135 pub fn with_times(
136 mut self,
137 issued_at: Option<SystemTime>,
138 expires_at: Option<SystemTime>,
139 ) -> Self {
140 self.issued_at = issued_at;
141 self.expires_at = expires_at;
142 self
143 }
144}
145
146/// Checks whether an access token identifier has been revoked.
147///
148/// JWT validators call this with the token's `jti` claim when a revocation
149/// checker is configured. Opaque-token validators can use the same contract
150/// with provider-specific token identifiers.
151#[async_trait]
152pub trait TokenRevocationChecker: Send + Sync {
153 /// Return revocation state for a token.
154 async fn check_token(
155 &self,
156 context: &TokenRevocationContext,
157 ) -> Result<TokenRevocationStatus, CredentialAuthError>;
158}
159
160/// SIP Digest nonce state from a replay store.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum DigestNonceStatus {
163 /// The nonce is known and not expired.
164 Active,
165 /// The nonce was issued by this service but has expired.
166 Expired,
167 /// The nonce is unknown to this service.
168 Unknown,
169}
170
171/// Shared replay store for clustered SIP Digest UAS deployments.
172///
173/// Implementations should key nonce-count replay by `(username, nonce)`, not by
174/// cnonce, because clients can change cnonce while replaying an old nonce-count.
175#[async_trait]
176pub trait DigestReplayStore: Send + Sync {
177 /// Record an issued nonce with its expiry time.
178 async fn record_nonce(
179 &self,
180 nonce: &str,
181 expires_at: SystemTime,
182 ) -> Result<(), CredentialAuthError>;
183
184 /// Return current nonce state.
185 async fn nonce_status(
186 &self,
187 nonce: &str,
188 now: SystemTime,
189 ) -> Result<DigestNonceStatus, CredentialAuthError>;
190
191 /// Atomically accept a nonce-count only if it is greater than the last
192 /// accepted value for `(username, nonce)`.
193 async fn accept_nonce_count(
194 &self,
195 username: &str,
196 nonce: &str,
197 nonce_count: u32,
198 ) -> Result<bool, CredentialAuthError>;
199}
200
201/// Auth scheme associated with an audit event.
202#[non_exhaustive]
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub enum AuthAuditScheme {
205 /// SIP Digest authentication.
206 Digest,
207 /// Bearer token authentication.
208 Bearer,
209 /// Basic username/password authentication.
210 Basic,
211 /// IMS AKA authentication.
212 Aka,
213 /// API key authentication.
214 ApiKey,
215 /// Direct password verification.
216 Password,
217 /// Token issuance, refresh, or revocation.
218 Token,
219 /// External or future scheme.
220 Other(String),
221}
222
223/// Security-relevant reason for an authentication failure.
224#[non_exhaustive]
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub enum AuthFailureReason {
227 /// No credential was supplied.
228 MissingCredential,
229 /// Credential was malformed.
230 MalformedCredential,
231 /// Credential was present but invalid.
232 InvalidCredential,
233 /// Credential used an unsupported auth scheme or algorithm.
234 UnsupportedScheme,
235 /// Credential was rejected by transport or deployment policy.
236 PolicyRejected,
237 /// Token was expired.
238 TokenExpired,
239 /// Token identifier was revoked.
240 TokenRevoked,
241 /// Digest nonce was stale and should be re-challenged.
242 StaleNonce,
243 /// Digest nonce-count or proof replay was rejected.
244 ReplayRejected,
245 /// Backing provider was unavailable.
246 ProviderUnavailable,
247 /// External or future failure reason.
248 Other(String),
249}
250
251/// Result captured by an auth audit event.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub enum AuthAuditOutcome {
254 /// Authentication succeeded.
255 Success,
256 /// Authentication failed with a categorized reason.
257 Failure(AuthFailureReason),
258}
259
260/// Redacted audit event for auth/security logging.
261///
262/// Events intentionally carry identifiers and metadata, not credential values.
263/// Do not put passwords, HA1 values, bearer tokens, API keys, full
264/// Authorization headers, or full JWTs into `metadata`.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct AuthAuditEvent {
267 /// Scheme or auth subsystem involved.
268 pub scheme: AuthAuditScheme,
269 /// Success/failure result.
270 pub outcome: AuthAuditOutcome,
271 /// User, subject, token id, SIP username, or API key id when known.
272 pub subject: Option<String>,
273 /// Auth realm, issuer, tenant, or provider name when known.
274 pub realm: Option<String>,
275 /// Source peer, IP, connection id, or SIP source when known.
276 pub peer: Option<String>,
277 /// Additional non-secret attributes.
278 pub metadata: BTreeMap<String, String>,
279}
280
281impl AuthAuditEvent {
282 /// Create an audit event without optional identifiers.
283 pub fn new(scheme: AuthAuditScheme, outcome: AuthAuditOutcome) -> Self {
284 Self {
285 scheme,
286 outcome,
287 subject: None,
288 realm: None,
289 peer: None,
290 metadata: BTreeMap::new(),
291 }
292 }
293
294 /// Attach a non-secret subject identifier.
295 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
296 self.subject = Some(subject.into());
297 self
298 }
299
300 /// Attach a non-secret realm, issuer, tenant, or provider name.
301 pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
302 self.realm = Some(realm.into());
303 self
304 }
305
306 /// Attach a peer identifier.
307 pub fn with_peer(mut self, peer: impl Into<String>) -> Self {
308 self.peer = Some(peer.into());
309 self
310 }
311
312 /// Attach non-secret metadata.
313 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
314 self.metadata.insert(key.into(), value.into());
315 self
316 }
317}
318
319/// Sink for security audit events.
320///
321/// Production implementations usually write to structured logging, SIEM, an
322/// audit database, or a message bus. Applications decide whether an unavailable
323/// sink is fail-open or fail-closed at the call site.
324#[async_trait]
325pub trait AuthAuditSink: Send + Sync {
326 /// Record a redacted auth audit event.
327 async fn record_auth_event(&self, event: AuthAuditEvent) -> Result<(), CredentialAuthError>;
328}
329
330/// Authentication operation subject to rate limits or lockout policy.
331#[non_exhaustive]
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub enum AuthRateLimitKind {
334 /// SIP REGISTER attempts.
335 SipRegister,
336 /// SIP request authentication outside REGISTER.
337 SipRequest,
338 /// Basic username/password verification.
339 BasicPassword,
340 /// Direct login password verification.
341 Password,
342 /// API key verification.
343 ApiKey,
344 /// Bearer token validation.
345 BearerToken,
346 /// Token issuance or refresh.
347 TokenIssuance,
348 /// SIP Digest validation.
349 Digest,
350 /// External or future operation.
351 Other(String),
352}
353
354/// Rate-limit key. Fields are optional so applications can key by peer, realm,
355/// subject, or any combination their deployment supports.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct AuthRateLimitKey {
358 /// Operation category.
359 pub kind: AuthRateLimitKind,
360 /// Subject or username when known.
361 pub subject: Option<String>,
362 /// Realm, issuer, tenant, or provider name when known.
363 pub realm: Option<String>,
364 /// Source peer, IP, connection id, or SIP source when known.
365 pub peer: Option<String>,
366}
367
368impl AuthRateLimitKey {
369 /// Create a key for an auth operation.
370 pub fn new(kind: AuthRateLimitKind) -> Self {
371 Self {
372 kind,
373 subject: None,
374 realm: None,
375 peer: None,
376 }
377 }
378
379 /// Attach a subject or username.
380 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
381 self.subject = Some(subject.into());
382 self
383 }
384
385 /// Attach a realm, issuer, tenant, or provider name.
386 pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
387 self.realm = Some(realm.into());
388 self
389 }
390
391 /// Attach a peer identifier.
392 pub fn with_peer(mut self, peer: impl Into<String>) -> Self {
393 self.peer = Some(peer.into());
394 self
395 }
396}
397
398/// Rate-limit decision.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum AuthRateLimitVerdict {
401 /// Request is allowed.
402 Allowed,
403 /// Request is denied by rate-limit or lockout policy.
404 Denied {
405 /// Suggested retry delay when known.
406 retry_after: Option<Duration>,
407 },
408}
409
410/// Provider contract for rate-limit and lockout policy.
411#[async_trait]
412pub trait AuthRateLimiter: Send + Sync {
413 /// Check whether an auth attempt is allowed before validating credentials.
414 async fn check_auth_attempt(
415 &self,
416 key: &AuthRateLimitKey,
417 ) -> Result<AuthRateLimitVerdict, CredentialAuthError>;
418
419 /// Record the outcome after an auth attempt is evaluated.
420 async fn record_auth_result(
421 &self,
422 key: &AuthRateLimitKey,
423 outcome: &AuthAuditOutcome,
424 ) -> Result<(), CredentialAuthError>;
425}