Skip to main content

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`.