yeti_types/auth.rs
1//! Authentication and authorization framework types.
2//!
3//! Defines: `AuthError`, `AuthIdentity`, `Access` (closed enum, the
4//! request authorization decision), the RBAC value tree
5//! (`User`/`Role`/`Permission`/`DatabasePermission`/`TablePermission`/
6//! `AttributePermission`), `AuthProvider` (plugin extension for
7//! authentication — produces `AuthIdentity`), `AuthPipeline`,
8//! `CookieJar`.
9
10use async_trait::async_trait;
11use bytes::Bytes;
12use http::Request;
13use serde::{Deserialize, Serialize};
14use std::borrow::Cow;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use crate::backend::BackendManager;
19
20// ============================================================================
21// AuthError
22// ============================================================================
23
24/// Authentication error.
25#[derive(Debug, Clone)]
26pub enum AuthError {
27 /// Invalid credentials
28 InvalidCredentials,
29 /// User not found
30 UserNotFound,
31 /// User inactive
32 UserInactive,
33 /// Token expired
34 TokenExpired,
35 /// Invalid token
36 InvalidToken,
37 /// Internal error
38 InternalError(String),
39}
40
41impl std::fmt::Display for AuthError {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::InvalidCredentials => write!(f, "Invalid credentials"),
45 Self::UserNotFound => write!(f, "User not found"),
46 Self::UserInactive => write!(f, "User inactive"),
47 Self::TokenExpired => write!(f, "Token expired"),
48 Self::InvalidToken => write!(f, "Invalid token"),
49 Self::InternalError(msg) => write!(f, "Internal error: {msg}"),
50 }
51 }
52}
53
54impl std::error::Error for AuthError {}
55
56// ============================================================================
57// AuthIdentity
58// ============================================================================
59
60/// Authentication identity — the result of successful authentication.
61///
62/// Represents WHO the user is (authentication), not what they can do
63/// (authorization). Mapped to a `User` with `Role` per-application
64/// during authorization.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum AuthIdentity {
67 /// Basic auth user (username verified via password)
68 Basic {
69 /// Username from Basic auth header
70 username: String,
71 },
72
73 /// JWT auth user (username from validated token)
74 Jwt {
75 /// Username (sub claim)
76 username: String,
77 /// Full JWT claims for additional context
78 claims: serde_json::Value,
79 },
80
81 /// OAuth user (identity from external provider)
82 OAuth {
83 /// Email address (if provided by provider)
84 email: Option<String>,
85 /// OAuth provider name (github, google, azure, etc.)
86 provider: String,
87 /// Full OAuth claims/user info
88 claims: serde_json::Value,
89 },
90
91 /// mTLS user (identity from client certificate)
92 Mtls {
93 /// Username (mapped from CN, SAN, or config)
94 username: String,
95 /// Certificate Common Name
96 cn: String,
97 /// Subject Alternative Names
98 sans: Vec<String>,
99 },
100}
101
102impl AuthIdentity {
103 /// Get the username/identifier for this identity.
104 ///
105 /// Returns a borrowed `&str` for most variants. The OAuth fallback
106 /// (no email) allocates a formatted string, so the return type is
107 /// `Cow<str>` to avoid cloning in the common case.
108 #[must_use]
109 pub fn username(&self) -> Cow<'_, str> {
110 match self {
111 Self::Basic { username } => Cow::Borrowed(username),
112 Self::Jwt { username, .. } => Cow::Borrowed(username),
113 Self::OAuth {
114 email, provider, ..
115 } => email.as_ref().map_or_else(
116 || Cow::Owned(format!("oauth:{provider}")),
117 |e| Cow::Borrowed(e.as_str()),
118 ),
119 Self::Mtls { username, .. } => Cow::Borrowed(username),
120 }
121 }
122
123 /// Get the auth method name.
124 #[must_use]
125 pub const fn method(&self) -> &'static str {
126 match self {
127 Self::Basic { .. } => "basic",
128 Self::Jwt { .. } => "jwt",
129 Self::OAuth { .. } => "oauth",
130 Self::Mtls { .. } => "mtls",
131 }
132 }
133}
134
135// ============================================================================
136// CookieJar
137// ============================================================================
138
139/// Simple cookie jar abstraction for auth providers.
140#[derive(Debug, Clone, Default)]
141pub struct CookieJar {
142 cookies: HashMap<String, String>,
143}
144
145impl CookieJar {
146 /// Create empty cookie jar.
147 #[must_use]
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 /// Parse cookies from request headers.
153 ///
154 /// Uses `get_all("Cookie")` to handle HTTP/2 split Cookie headers.
155 pub fn from_request<B>(req: &Request<B>) -> Self {
156 let mut cookies = HashMap::new();
157
158 for cookie_header in req.headers().get_all("Cookie") {
159 if let Ok(cookie_str) = cookie_header.to_str() {
160 for cookie in cookie_str.split(';') {
161 let cookie = cookie.trim();
162 if let Some((name, value)) = cookie.split_once('=') {
163 cookies.insert(name.to_owned(), value.to_owned());
164 }
165 }
166 }
167 }
168
169 Self { cookies }
170 }
171
172 /// Get a cookie value by name.
173 #[must_use]
174 pub fn get(&self, name: &str) -> Option<&str> {
175 self.cookies.get(name).map(std::string::String::as_str)
176 }
177
178 /// Set a cookie value (for testing).
179 pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
180 self.cookies.insert(name.into(), value.into());
181 }
182}
183
184// ============================================================================
185// RBAC value types (moved from yeti-auth 2026-05-14, Context-contract
186// option 1: closed enum, no dynamic dispatch on the request hot path).
187//
188// User / Role / Permission live at L0 so Context can hold them by
189// value without a dyn-trait indirection. yeti-auth keeps its own auth
190// flow (login / OAuth / JWT) and re-exports these types for the
191// pre-refactor import paths.
192// ============================================================================
193
194/// Reserved role id for the platform's built-in unrestricted role.
195pub const ROLE_SUPER_USER: &str = "super_user";
196
197/// Per-attribute permission flags.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct AttributePermission {
200 /// Allow reading this attribute.
201 pub read: bool,
202 /// Allow writing this attribute.
203 pub write: bool,
204}
205
206/// Table-level CRUD permission grants.
207///
208/// Each bool maps 1:1 to a CRUD verb. `attribute_permissions` is a
209/// per-field override map: if a field appears with `read=false`, the
210/// field is hidden from the response even when `read=true` at the
211/// table level.
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213#[expect(
214 clippy::struct_excessive_bools,
215 reason = "RBAC DTO; each bool maps 1:1 to a CRUD verb (read, insert, update, delete) — these are user-visible permission flags configured per-role in the auth manifest. Bitflags would diverge from the manifest serialization shape."
216)]
217pub struct TablePermission {
218 /// Allow reading records.
219 pub read: bool,
220 /// Allow inserting records.
221 pub insert: bool,
222 /// Allow updating records.
223 pub update: bool,
224 /// Allow deleting records.
225 pub delete: bool,
226 /// Per-attribute permission overrides.
227 #[serde(default)]
228 pub attribute_permissions: HashMap<String, AttributePermission>,
229}
230
231/// Database-level permissions — owns a per-table grant map.
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct DatabasePermission {
234 /// Per-table permission grants.
235 pub tables: HashMap<String, TablePermission>,
236}
237
238/// Role permissions — owns a per-database permission tree plus the
239/// platform-wide `super_user` toggle.
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct Permission {
242 /// Whether this role has unrestricted access.
243 #[serde(default)]
244 pub super_user: bool,
245 /// Per-database permission grants.
246 #[serde(default)]
247 pub databases: HashMap<String, DatabasePermission>,
248}
249
250/// Named role assignable to a user.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct Role {
253 /// Unique role identifier.
254 pub id: String,
255 /// Human-readable role name.
256 pub name: String,
257 /// Permission grants for this role.
258 pub permissions: Permission,
259}
260
261/// Authenticated user — username + assigned role. Held inside
262/// [`Access::User`] when a request has been authenticated; the
263/// `Arc` keeps `Access` cheap to clone across the pipeline.
264#[derive(Debug, Clone)]
265pub struct User {
266 /// The user's login name.
267 pub username: String,
268 /// The user's assigned role.
269 pub role: Role,
270}
271
272impl User {
273 /// Build a user with the given role.
274 #[must_use]
275 pub const fn new(username: String, role: Role) -> Self {
276 Self { username, role }
277 }
278
279 /// Build a user with the built-in `super_user` role.
280 #[must_use]
281 pub fn super_user(username: String) -> Self {
282 Self {
283 username,
284 role: Role {
285 id: ROLE_SUPER_USER.to_owned(),
286 name: "Super User".to_owned(),
287 permissions: Permission {
288 super_user: true,
289 databases: HashMap::new(),
290 },
291 },
292 }
293 }
294
295 /// Resolve the table grant for `(database, table)`, trying an
296 /// exact match first and falling back to a case-insensitive
297 /// table-name match (roles use schema-declared `PascalCase`; URLs
298 /// can come in lowercase).
299 fn table_perm(&self, database: &str, table: &str) -> Option<&TablePermission> {
300 let db = self.role.permissions.databases.get(database)?;
301 db.tables.get(table).or_else(|| {
302 let lower = table.to_lowercase();
303 db.tables
304 .iter()
305 .find(|(k, _)| k.to_lowercase() == lower)
306 .map(|(_, v)| v)
307 })
308 }
309
310 /// `true` if this user's role has `super_user = true`.
311 #[must_use]
312 pub const fn is_super_user(&self) -> bool {
313 self.role.permissions.super_user
314 }
315
316 /// Role identifier (`Role.id`).
317 #[must_use]
318 pub fn role(&self) -> &str {
319 &self.role.id
320 }
321
322 /// Username.
323 #[must_use]
324 pub fn username(&self) -> &str {
325 &self.username
326 }
327
328 /// May this user read records from `database.table`?
329 #[must_use]
330 pub fn can_read_table(&self, database: &str, table: &str) -> bool {
331 if self.is_super_user() {
332 return true;
333 }
334 self.table_perm(database, table).is_some_and(|t| t.read)
335 }
336
337 /// May this user insert records into `database.table`?
338 #[must_use]
339 pub fn can_insert_table(&self, database: &str, table: &str) -> bool {
340 if self.is_super_user() {
341 return true;
342 }
343 self.table_perm(database, table).is_some_and(|t| t.insert)
344 }
345
346 /// May this user update records in `database.table`?
347 #[must_use]
348 pub fn can_update_table(&self, database: &str, table: &str) -> bool {
349 if self.is_super_user() {
350 return true;
351 }
352 self.table_perm(database, table).is_some_and(|t| t.update)
353 }
354
355 /// May this user delete records from `database.table`?
356 #[must_use]
357 pub fn can_delete_table(&self, database: &str, table: &str) -> bool {
358 if self.is_super_user() {
359 return true;
360 }
361 self.table_perm(database, table).is_some_and(|t| t.delete)
362 }
363
364 /// May this user read field `attr` on `database.table`?
365 #[must_use]
366 pub fn can_read_attribute(&self, database: &str, table: &str, attr: &str) -> bool {
367 if self.is_super_user() {
368 return true;
369 }
370 if !self.can_read_table(database, table) {
371 return false;
372 }
373 self.table_perm(database, table)
374 .and_then(|t| t.attribute_permissions.get(attr))
375 .is_none_or(|a| a.read)
376 }
377
378 /// May this user write field `attr` on `database.table`?
379 #[must_use]
380 pub fn can_write_attribute(&self, database: &str, table: &str, attr: &str) -> bool {
381 if self.is_super_user() {
382 return true;
383 }
384 let table_perm = self.table_perm(database, table);
385 let can_write_table = table_perm.is_some_and(|t| t.insert || t.update);
386 if !can_write_table {
387 return false;
388 }
389 table_perm
390 .and_then(|t| t.attribute_permissions.get(attr))
391 .is_none_or(|a| a.write)
392 }
393
394 /// `true` if no per-attribute restrictions apply for `database.table`.
395 #[must_use]
396 pub fn has_unrestricted_attributes(&self, database: &str, table: &str) -> bool {
397 if self.is_super_user() {
398 return true;
399 }
400 // No table perm OR table perm with empty attribute map means
401 // attributes are unrestricted at the table level.
402 self.table_perm(database, table)
403 .is_none_or(|t| t.attribute_permissions.is_empty())
404 }
405}
406
407// ============================================================================
408// Access — the request-context authorization decision (closed enum).
409// ============================================================================
410
411/// Authorization decision attached to a request `Context`.
412///
413/// Closed enum (no `dyn`): the dispatch layer reads
414/// `ctx.access.can_read_table(db, t)` and the compiler devirtualizes
415/// to a stack-resident match. Customer authentication plugins extend
416/// via [`AuthProvider`] — they produce an [`AuthIdentity`] which the
417/// framework resolves into a `User`; they do not implement Access
418/// directly.
419#[derive(Debug, Clone, Default)]
420pub enum Access {
421 /// Unauthenticated request. Every permission check returns
422 /// `false`; the only way through is a resource that explicitly
423 /// opts into public access (e.g. via `@access(public: [read])`).
424 #[default]
425 None,
426 /// Authenticated user — RBAC decisions delegate to the inner
427 /// [`User`]. Wrapped in `Arc` so cloning `Access` across the
428 /// request pipeline is one atomic bump, not a deep `Role` copy.
429 User(Arc<User>),
430}
431
432impl Access {
433 /// `true` if the request authenticated as a user (any user).
434 #[must_use]
435 pub const fn is_authenticated(&self) -> bool {
436 matches!(self, Self::User(_))
437 }
438
439 /// `true` if the request authenticated as a super-user role.
440 #[must_use]
441 pub fn is_super_user(&self) -> bool {
442 match self {
443 Self::None => false,
444 Self::User(u) => u.is_super_user(),
445 }
446 }
447
448 /// Role identifier of the authenticated user, or empty string
449 /// if unauthenticated.
450 #[must_use]
451 pub fn role(&self) -> &str {
452 match self {
453 Self::None => "",
454 Self::User(u) => u.role(),
455 }
456 }
457
458 /// Username of the authenticated user, or empty string if
459 /// unauthenticated.
460 #[must_use]
461 pub fn username(&self) -> &str {
462 match self {
463 Self::None => "",
464 Self::User(u) => u.username(),
465 }
466 }
467
468 /// `Some(user)` if authenticated, `None` otherwise. Useful when
469 /// the caller needs to reach into User-specific fields beyond
470 /// the helper methods on Access.
471 #[must_use]
472 pub fn user(&self) -> Option<&User> {
473 match self {
474 Self::None => None,
475 Self::User(u) => Some(u),
476 }
477 }
478
479 /// May the requester read records from `database.table`?
480 #[must_use]
481 pub fn can_read_table(&self, database: &str, table: &str) -> bool {
482 match self {
483 Self::None => false,
484 Self::User(u) => u.can_read_table(database, table),
485 }
486 }
487
488 /// May the requester insert records into `database.table`?
489 #[must_use]
490 pub fn can_insert_table(&self, database: &str, table: &str) -> bool {
491 match self {
492 Self::None => false,
493 Self::User(u) => u.can_insert_table(database, table),
494 }
495 }
496
497 /// May the requester update records in `database.table`?
498 #[must_use]
499 pub fn can_update_table(&self, database: &str, table: &str) -> bool {
500 match self {
501 Self::None => false,
502 Self::User(u) => u.can_update_table(database, table),
503 }
504 }
505
506 /// May the requester delete records from `database.table`?
507 #[must_use]
508 pub fn can_delete_table(&self, database: &str, table: &str) -> bool {
509 match self {
510 Self::None => false,
511 Self::User(u) => u.can_delete_table(database, table),
512 }
513 }
514
515 /// May the requester read field `attr` on `database.table`?
516 #[must_use]
517 pub fn can_read_attribute(&self, database: &str, table: &str, attr: &str) -> bool {
518 match self {
519 Self::None => false,
520 Self::User(u) => u.can_read_attribute(database, table, attr),
521 }
522 }
523
524 /// May the requester write field `attr` on `database.table`?
525 #[must_use]
526 pub fn can_write_attribute(&self, database: &str, table: &str, attr: &str) -> bool {
527 match self {
528 Self::None => false,
529 Self::User(u) => u.can_write_attribute(database, table, attr),
530 }
531 }
532
533 /// `true` if no per-attribute restrictions apply for `database.table`.
534 /// `Access::None` returns `true` here because there is no user
535 /// whose attribute grants need consulting — callers gate on
536 /// [`is_authenticated`](Self::is_authenticated) separately if they
537 /// need to distinguish "no user" from "user with full attributes".
538 #[must_use]
539 pub fn has_unrestricted_attributes(&self, database: &str, table: &str) -> bool {
540 match self {
541 Self::None => true,
542 Self::User(u) => u.has_unrestricted_attributes(database, table),
543 }
544 }
545
546 /// Filter `attrs` to only those the requester may read on
547 /// `database.table`. Super-users keep the full list; unauthenticated
548 /// requests get an empty list.
549 #[must_use]
550 pub fn filter_readable_attributes<'a>(
551 &self,
552 database: &str,
553 table: &str,
554 attrs: &[&'a str],
555 ) -> Vec<&'a str> {
556 match self {
557 Self::None => Vec::new(),
558 Self::User(u) => {
559 if u.is_super_user() {
560 return attrs.to_vec();
561 }
562 attrs
563 .iter()
564 .filter(|a| u.can_read_attribute(database, table, a))
565 .copied()
566 .collect()
567 },
568 }
569 }
570
571 /// Verify every attribute in `attrs` is writable. Super-users
572 /// always succeed; unauthenticated requests fail listing every
573 /// attribute as unauthorized.
574 ///
575 /// # Errors
576 ///
577 /// Returns `Err(unauthorized)` with the list of attribute names
578 /// the requester may not write.
579 pub fn validate_writable_attributes(
580 &self,
581 database: &str,
582 table: &str,
583 attrs: &[&str],
584 ) -> std::result::Result<(), Vec<String>> {
585 match self {
586 Self::None => Err(attrs.iter().map(|s| (*s).to_owned()).collect()),
587 Self::User(u) => {
588 if u.is_super_user() {
589 return Ok(());
590 }
591 let unauthorized: Vec<String> = attrs
592 .iter()
593 .filter(|a| !u.can_write_attribute(database, table, a))
594 .map(|s| (*s).to_owned())
595 .collect();
596 if unauthorized.is_empty() {
597 Ok(())
598 } else {
599 Err(unauthorized)
600 }
601 },
602 }
603 }
604
605 /// Filter a JSON object in-place, retaining only attributes the
606 /// requester may read. Super-users pass-through. Unauthenticated
607 /// requests reduce the object to empty.
608 pub fn filter_record(&self, database: &str, table: &str, record: &mut serde_json::Value) {
609 match self {
610 Self::None => {
611 if let Some(obj) = record.as_object_mut() {
612 obj.clear();
613 }
614 },
615 Self::User(u) => {
616 if u.is_super_user() {
617 return;
618 }
619 if let Some(obj) = record.as_object_mut() {
620 obj.retain(|key, _| u.can_read_attribute(database, table, key));
621 }
622 },
623 }
624 }
625
626 /// Filter every record in `records` via [`filter_record`](Self::filter_record).
627 pub fn filter_records(&self, database: &str, table: &str, records: &mut [serde_json::Value]) {
628 for record in records {
629 self.filter_record(database, table, record);
630 }
631 }
632}
633
634// ============================================================================
635// AuthProvider trait
636// ============================================================================
637
638/// Authentication provider trait.
639///
640/// Providers are run in priority order (highest first) until one returns
641/// an identity.
642#[async_trait]
643pub trait AuthProvider: Send + Sync {
644 /// Attempt to authenticate the request.
645 ///
646 /// Returns:
647 /// - `Ok(Some(identity))` if credentials are valid
648 /// - `Ok(None)` if this provider doesn't apply
649 /// - `Err(...)` if credentials are present but invalid
650 async fn authenticate(
651 &self,
652 req: &Request<Bytes>,
653 cookies: &CookieJar,
654 backend: Option<&BackendManager>,
655 ) -> std::result::Result<Option<AuthIdentity>, AuthError>;
656
657 /// Priority for ordering (higher = checked first).
658 fn priority(&self) -> i32 {
659 100
660 }
661
662 /// Name for logging and debugging.
663 fn name(&self) -> &str;
664}
665
666// ============================================================================
667// AuthLifecycleHook
668// ============================================================================
669
670/// Minimal user view handed to lifecycle hooks.
671///
672/// The legacy static OAuth/JWT path doesn't always have a fully
673/// resolved [`User`] (with a populated [`Role`]) at every hook site —
674/// login fires before role resolution, logout only knows the session
675/// subject. `LifecycleUser` is the common denominator both paths can
676/// always produce: a username plus the optional email + provider that
677/// were available. Hooks that need the full RBAC role read it from the
678/// `Context` they receive elsewhere.
679#[derive(Debug, Clone, Default, Serialize, Deserialize)]
680pub struct LifecycleUser {
681 /// Stable subject identifier (username / `sub`).
682 pub username: String,
683 /// Email, when the authenticating path knew it.
684 pub email: Option<String>,
685 /// Authenticating provider (`"github"`, `"google"`, `"basic"`,
686 /// `"jwt"`, …) when known.
687 pub provider: Option<String>,
688}
689
690impl LifecycleUser {
691 /// Build a `LifecycleUser` from a username only.
692 #[must_use]
693 pub fn from_username(username: impl Into<String>) -> Self {
694 Self {
695 username: username.into(),
696 email: None,
697 provider: None,
698 }
699 }
700
701 /// Derive a `LifecycleUser` from an [`AuthIdentity`].
702 #[must_use]
703 pub fn from_identity(identity: &AuthIdentity) -> Self {
704 let provider = Some(identity.method().to_owned());
705 match identity {
706 AuthIdentity::OAuth {
707 email, provider: p, ..
708 } => Self {
709 username: identity.username().into_owned(),
710 email: email.clone(),
711 provider: Some(p.clone()),
712 },
713 _ => Self {
714 username: identity.username().into_owned(),
715 email: None,
716 provider,
717 },
718 }
719 }
720}
721
722/// Token data surfaced to [`AuthLifecycleHook::on_token_refresh`].
723///
724/// Refresh fires after a provider hands back a fresh access token; the
725/// hook gets the new bearer plus its remaining lifetime so it can
726/// re-prime downstream caches / re-issue derived credentials.
727#[derive(Debug, Clone, Default)]
728pub struct LifecycleToken {
729 /// The freshly-minted provider access token.
730 pub access_token: String,
731 /// Remaining TTL in seconds, when the provider reported one.
732 pub expires_in: Option<u64>,
733}
734
735/// Auth lifecycle hook — fires on login, logout, and token refresh of
736/// the **legacy static** auth path (Basic / JWT / OAuth session).
737///
738/// This is the successor to the deleted `AuthHook::on_resolve_role`
739/// trait (removed in ADR-006, which moved *role resolution* to the
740/// `tower::Service<ResolveRequest>` plugin surface). Role resolution
741/// is intentionally NOT part of this trait — that concern already has
742/// a home. What had no home was the request to observe the
743/// authentication *events themselves* (`on_login`, `on_logout`,
744/// `on_token_refresh`); this trait fills exactly that gap.
745///
746/// All methods default to no-ops so implementors override only what
747/// they care about. Hooks are observational: they cannot deny a login
748/// (the auth posture is decided before they run) — returning is the
749/// only contract. They run after the security decision, never as part
750/// of it, so a buggy or slow hook can never weaken authentication.
751#[async_trait]
752pub trait AuthLifecycleHook: Send + Sync {
753 /// Hook name for logging / debugging.
754 fn name(&self) -> &'static str {
755 "anonymous"
756 }
757
758 /// Fired after a user successfully authenticates and a session /
759 /// token is issued. Default no-op.
760 async fn on_login(&self, _user: &LifecycleUser) {}
761
762 /// Fired after a user's session / token is invalidated. Default no-op.
763 async fn on_logout(&self, _user: &LifecycleUser) {}
764
765 /// Fired after an OAuth access token is proactively or explicitly
766 /// refreshed. Default no-op.
767 async fn on_token_refresh(&self, _user: &LifecycleUser, _token: &LifecycleToken) {}
768}
769
770// ============================================================================
771// AuthPipeline
772// ============================================================================
773
774/// Authentication pipeline — runs providers in priority order.
775pub struct AuthPipeline {
776 providers: Vec<Arc<dyn AuthProvider>>,
777}
778
779impl std::fmt::Debug for AuthPipeline {
780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781 f.debug_struct("AuthPipeline")
782 .field("providers", &self.providers.len())
783 .finish_non_exhaustive()
784 }
785}
786
787impl Default for AuthPipeline {
788 fn default() -> Self {
789 Self::new()
790 }
791}
792
793impl AuthPipeline {
794 /// Create empty auth pipeline.
795 #[must_use]
796 pub fn new() -> Self {
797 Self {
798 providers: Vec::new(),
799 }
800 }
801
802 /// Register an auth provider (sorted by priority — highest first).
803 pub fn register(&mut self, provider: Arc<dyn AuthProvider>) {
804 self.providers.push(provider);
805 self.providers
806 .sort_by_key(|p| std::cmp::Reverse(p.priority()));
807 }
808
809 /// Register an auth provider in caller-determined order (no sorting).
810 pub fn register_ordered(&mut self, provider: Arc<dyn AuthProvider>) {
811 self.providers.push(provider);
812 }
813
814 /// Get number of registered providers.
815 #[must_use]
816 pub fn len(&self) -> usize {
817 self.providers.len()
818 }
819
820 /// Check if pipeline is empty.
821 #[must_use]
822 pub fn is_empty(&self) -> bool {
823 self.providers.is_empty()
824 }
825
826 /// Run authentication — returns identity if any provider succeeds.
827 pub async fn authenticate(
828 &self,
829 req: &Request<Bytes>,
830 cookies: &CookieJar,
831 backend: Option<&BackendManager>,
832 app_id: &str,
833 ) -> Option<AuthIdentity> {
834 let mut had_error = false;
835 for provider in &self.providers {
836 match provider.authenticate(req, cookies, backend).await {
837 Ok(Some(identity)) => {
838 tracing::debug!(
839 "[{}] Authenticated via {} as {}",
840 app_id,
841 provider.name(),
842 identity.username()
843 );
844 return Some(identity);
845 },
846 Ok(None) => {},
847 Err(e) => {
848 tracing::error!(
849 "[{}] Auth provider {} error: {}",
850 app_id,
851 provider.name(),
852 e
853 );
854 had_error = true;
855 },
856 }
857 }
858
859 if had_error {
860 tracing::error!(
861 "[{}] All auth providers failed or returned no identity",
862 app_id
863 );
864 } else {
865 tracing::trace!("[{}] No authentication credentials provided", app_id);
866 }
867 None
868 }
869}
870
871// `tower::Service<ResolveRequest, Response = ResolveResponse>` — see
872// `crate::plugins::auth`.