Skip to main content

autumn_web/
auth.rs

1//! Authentication utilities for Autumn applications.
2//!
3//! Provides password hashing, an [`Auth<T>`] extractor for retrieving the
4//! authenticated user, and a [`RequireAuth`] middleware layer for protecting
5//! routes.
6//!
7//! ## Quick start
8//!
9//! ```rust,no_run
10//! use autumn_web::prelude::*;
11//! use autumn_web::auth::{Auth, hash_password, verify_password};
12//! use autumn_web::session::Session;
13//!
14//! #[derive(Clone)]
15//! struct User { id: i64, name: String }
16//!
17//! #[post("/register")]
18//! async fn register() -> AutumnResult<&'static str> {
19//!     let hashed = hash_password("secret123").await?;
20//!     // Save hashed password to database...
21//!     Ok("registered")
22//! }
23//!
24//! #[post("/login")]
25//! async fn login(session: Session) -> AutumnResult<&'static str> {
26//!     // Verify credentials...
27//!     let stored_hash = "$2b$12$..."; // from database
28//!     if verify_password("secret123", stored_hash).await? {
29//!         session.insert("user_id", "42").await;
30//!         Ok("logged in")
31//!     } else {
32//!         Err(AutumnError::bad_request_msg("invalid credentials"))
33//!     }
34//! }
35//! ```
36//!
37//! ## Password hashing
38//!
39//! Uses bcrypt with a default cost of 12. The [`hash_password`] and
40//! [`verify_password`] functions are simple wrappers that return
41//! [`AutumnResult`](crate::AutumnResult).
42//!
43//! ## The `Auth<T>` extractor
44//!
45//! [`Auth<T>`] extracts the authenticated user from request extensions.
46//! It is typically populated by a custom middleware which might call
47//! `request.extensions_mut().insert(user)` in a handler. Returns `401 Unauthorized` if no
48//! user is present.
49//!
50//! ## Route protection with `RequireAuth`
51//!
52//! The [`RequireAuth`] layer rejects unauthenticated requests with
53//! `401 Unauthorized` before they reach the handler. It checks for the
54//! presence of a session key (default: `"user_id"`).
55
56#[cfg(feature = "oauth2")]
57use std::collections::HashMap;
58use std::future::Future;
59use std::pin::Pin;
60use std::sync::Arc;
61use std::task::{Context, Poll};
62#[cfg(feature = "oauth2")]
63use std::time::Duration;
64
65use axum::extract::FromRequestParts;
66use axum::response::{IntoResponse, Response};
67use http::StatusCode;
68use http::request::Parts;
69#[cfg(feature = "oauth2")]
70use jsonwebtoken::jwk::JwkSet;
71#[cfg(feature = "oauth2")]
72use serde::Deserialize;
73#[cfg(feature = "oauth2")]
74use url::Url;
75
76pub mod password;
77pub use password::{
78    BreachCheck, PasswordConfig, PasswordFailure, PasswordPolicy, PasswordValidation,
79    validate_password,
80};
81
82pub mod remember;
83pub use remember::{
84    DEFAULT_ROTATION_GRACE_SECS, RememberConfig, RememberCredential, RememberDecision,
85    RememberRecord, build_remember_clear_cookie, build_remember_cookie, constant_time_eq,
86    default_rotation_grace, evaluate_remember, format_remember_cookie_value,
87    generate_remember_credential, generate_token, hash_remember_token, parse_remember_cookie_value,
88    verify_remember_token,
89};
90
91// ── Password hashing ────────────────────────────────────────────
92
93/// Default bcrypt cost factor.
94const DEFAULT_BCRYPT_COST: u32 = 12;
95
96/// Hash a plaintext password using bcrypt.
97///
98/// Returns the hashed password string suitable for database storage.
99///
100/// # Errors
101///
102/// Returns an error if bcrypt hashing fails (extremely unlikely).
103///
104/// # Examples
105///
106/// ```rust
107/// use autumn_web::auth::hash_password;
108///
109/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
110/// let hashed = hash_password("my_secret").await.unwrap();
111/// assert!(hashed.starts_with("$2b$"));
112/// # });
113/// ```
114pub async fn hash_password(password: &str) -> crate::AutumnResult<String> {
115    let password = password.to_string();
116    tokio::task::spawn_blocking(move || {
117        bcrypt::hash(password, DEFAULT_BCRYPT_COST)
118            .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))
119    })
120    .await
121    .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))?
122}
123
124/// Verify a plaintext password against a bcrypt hash.
125///
126/// Returns `true` if the password matches the hash.
127///
128/// # Errors
129///
130/// Returns an error if bcrypt verification fails (e.g., invalid hash format).
131///
132/// # Examples
133///
134/// ```rust
135/// use autumn_web::auth::{hash_password, verify_password};
136///
137/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
138/// let hashed = hash_password("my_secret").await.unwrap();
139/// assert!(verify_password("my_secret", &hashed).await.unwrap());
140/// assert!(!verify_password("wrong_password", &hashed).await.unwrap());
141/// # });
142/// ```
143pub async fn verify_password(password: &str, hash: &str) -> crate::AutumnResult<bool> {
144    let password = password.to_string();
145
146    // Parse the hash format outside the blocking task.
147    // A valid bcrypt hash is typically 60 characters and starts with "$".
148    let is_valid_format = hash.len() == 60 && hash.starts_with('$');
149
150    let hash_to_verify = if is_valid_format {
151        hash.to_string()
152    } else {
153        // To prevent timing attacks, perform a dummy verification against a known hash.
154        "$2b$12$KIXe8K4j1sH6/xH.x9d71uJ5Jk8t6O4m6Q110g4H8y1r6J6O6O6O6".to_string()
155    };
156
157    let result = tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash_to_verify))
158        .await
159        .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))?;
160
161    if !is_valid_format {
162        return Ok(false);
163    }
164
165    result.map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))
166}
167
168// ── Runtime check for #[secured] macro ──────────────────────────
169
170/// Runtime authentication and authorization check used by the
171/// `#[secured]` proc macro. **Not intended for direct use** -- use
172/// `#[secured]` instead.
173///
174/// Checks the session for the configured auth key (default: `"user_id"`).
175/// If `roles` is non-empty, also checks that the session's `"role"` value
176/// matches at least one of the given roles.
177///
178/// Returns `401 Unauthorized` if not authenticated, or `403 Forbidden`
179/// if the user lacks the required role.
180#[doc(hidden)]
181pub async fn __check_secured(
182    session: &crate::session::Session,
183    roles: &[&str],
184) -> crate::AutumnResult<()> {
185    __check_secured_with_key(session, "user_id", roles).await
186}
187
188/// Runtime check used by `#[secured]` when `AppState` is available.
189///
190/// Accepts the configured auth session key so generated login/signup/reset
191/// handlers and `#[secured]` resolve authentication through the same session
192/// entry.
193#[doc(hidden)]
194pub async fn __check_secured_with_key(
195    session: &crate::session::Session,
196    auth_session_key: &str,
197    roles: &[&str],
198) -> crate::AutumnResult<()> {
199    // Check authentication: session must contain the auth key
200    let Some(user_id) = session.get(auth_session_key).await else {
201        return Err(crate::AutumnError::unauthorized_msg(
202            "authentication required",
203        ));
204    };
205
206    // Publish the authenticated principal as the request's current actor
207    // (#1383) so generated repository/audit writes auto-attribute to it, and
208    // tag the request-scoped log context (#1169) with the same user so every
209    // subsequent event automatically carries `user_id`.
210    //
211    // Seed the actor only if no stronger/earlier principal is already set. This
212    // `#[secured]` role check runs inside the handler body, *inner* to the auth
213    // middleware layers (`RequireApiToken` bearer, `RequireAuth` session). On a
214    // route that combines `RequireApiToken` with `#[secured]`, the bearer
215    // middleware has already published the token principal by the time this runs;
216    // a request that *also* carries a session cookie must stay attributed to the
217    // token principal, so we must not clobber it with the session user here.
218    // (`log::context::set_user_id` for #1169 is independent and stays
219    // unconditional.)
220    if crate::current::Current::actor().is_none() {
221        crate::current::Current::set_actor(user_id.clone());
222    }
223    crate::log::context::set_user_id(user_id);
224
225    // Check authorization: if roles are specified, the session's "role"
226    // must match at least one of them
227    if !roles.is_empty() {
228        let user_role = session.get("role").await.unwrap_or_default();
229        if !roles.iter().any(|&r| r == user_role) {
230            return Err(crate::AutumnError::forbidden_msg(
231                "insufficient permissions",
232            ));
233        }
234    }
235
236    Ok(())
237}
238
239/// Runtime scope check used by `#[secured(scopes = [...])]`. **Not intended for
240/// direct use** — use `#[secured]` instead.
241///
242/// Default-deny: with a non-empty `required_scopes`, every required scope must
243/// be present in the authenticating token's granted scopes, otherwise `403
244/// Forbidden`. An empty requirement is a no-op (`Ok`). A token with no granted
245/// scopes (`granted == None`) is denied whenever a scope is required, so a pure
246/// service token that lacks the scope is rejected.
247// Async (with no await) to mirror `__check_secured_with_key`, so the macro can
248// uniformly `.await` whichever check it emits.
249#[doc(hidden)]
250#[allow(clippy::unused_async)]
251pub async fn __check_secured_scopes(
252    granted: Option<&ApiTokenScopes>,
253    required_scopes: &[&str],
254) -> crate::AutumnResult<()> {
255    if required_scopes.is_empty() {
256        return Ok(());
257    }
258    let granted: &[String] = granted.map_or(&[], |g| g.0.as_slice());
259    if required_scopes
260        .iter()
261        .all(|req| granted.iter().any(|g| g == req))
262    {
263        Ok(())
264    } else {
265        Err(crate::AutumnError::forbidden_msg("insufficient scope"))
266    }
267}
268
269// ── Auth<T> extractor ───────────────────────────────────────────
270
271/// Extractor that retrieves the authenticated user from request extensions.
272///
273/// Handlers can declare `Auth<MyUser>` as a parameter to access the
274/// current user. If no user is present in the request extensions,
275/// a `401 Unauthorized` response is returned automatically.
276///
277/// ## Populating the user
278///
279/// The user is typically inserted into request extensions by middleware.
280/// For example, a custom middleware can load the user from the session
281/// and call `request.extensions_mut().insert(user)`.
282///
283/// ## Examples
284///
285/// ```rust,no_run
286/// use autumn_web::prelude::*;
287/// use autumn_web::auth::Auth;
288///
289/// #[derive(Clone)]
290/// struct CurrentUser { id: i64, name: String }
291///
292/// #[get("/profile")]
293/// async fn profile(Auth(user): Auth<CurrentUser>) -> String {
294///     format!("Hello, {}!", user.name)
295/// }
296/// ```
297pub struct Auth<T>(pub T);
298
299impl<T, S> FromRequestParts<S> for Auth<T>
300where
301    T: Clone + Send + Sync + 'static,
302    S: Send + Sync,
303{
304    type Rejection = AuthRejection;
305
306    fn from_request_parts(
307        parts: &mut Parts,
308        _state: &S,
309    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
310        let user = parts.extensions.get::<T>().cloned();
311        async move { user.map_or_else(|| Err(AuthRejection), |user| Ok(Self(user))) }
312    }
313}
314
315/// Rejection type for [`Auth<T>`] when no authenticated user is present.
316#[derive(Debug)]
317pub struct AuthRejection;
318
319impl IntoResponse for AuthRejection {
320    fn into_response(self) -> Response {
321        crate::AutumnError::unauthorized_msg("authentication required").into_response()
322    }
323}
324
325impl std::fmt::Display for AuthRejection {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        f.write_str("authentication required")
328    }
329}
330
331// ── RequireAuth middleware ───────────────────────────────────────
332
333/// Tower [`tower::Layer`] that rejects unauthenticated requests with `401`.
334///
335/// Checks for a specific key in the session to determine if the request
336/// is authenticated. If the key is missing, the request is rejected before
337/// reaching the handler.
338///
339/// On success, the resolved principal value is also inserted as a
340/// [`crate::security::RateLimitPrincipal`] extension so that
341/// `key_strategy = "authenticated_principal"` works out of the box.
342///
343/// # Examples
344///
345/// ```rust,no_run
346/// use autumn_web::auth::RequireAuth;
347/// use autumn_web::reexports::axum::{Router, routing::get};
348/// use autumn_web::AppState;
349///
350/// // Protect all routes under /admin
351/// let admin_routes = Router::<AppState>::new()
352///     .route("/dashboard", get(|| async { "admin" }))
353///     .layer(RequireAuth::new("user_id"));
354/// ```
355#[derive(Clone)]
356pub struct RequireAuth {
357    session_key: Arc<str>,
358}
359
360impl RequireAuth {
361    /// Create a new `RequireAuth` layer that checks for the given session key.
362    pub fn new(session_key: impl Into<String>) -> Self {
363        Self {
364            session_key: Arc::from(session_key.into()),
365        }
366    }
367}
368
369impl<S> tower::Layer<S> for RequireAuth {
370    type Service = RequireAuthService<S>;
371
372    fn layer(&self, inner: S) -> Self::Service {
373        RequireAuthService {
374            inner,
375            session_key: Arc::clone(&self.session_key),
376        }
377    }
378}
379
380/// Tower [`tower::Service`] produced by [`RequireAuth`].
381#[derive(Clone)]
382pub struct RequireAuthService<S> {
383    inner: S,
384    session_key: Arc<str>,
385}
386
387impl<S, ResBody> tower::Service<axum::extract::Request> for RequireAuthService<S>
388where
389    S: tower::Service<axum::extract::Request, Response = Response<ResBody>>
390        + Clone
391        + Send
392        + 'static,
393    S::Future: Send + 'static,
394    S::Error: Send + 'static,
395    ResBody: From<String> + Default + Send + 'static,
396{
397    type Response = Response<ResBody>;
398    type Error = S::Error;
399    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
400
401    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
402        self.inner.poll_ready(cx)
403    }
404
405    fn call(&mut self, mut req: axum::extract::Request) -> Self::Future {
406        let session_key = Arc::clone(&self.session_key);
407        let mut inner = self.inner.clone();
408        std::mem::swap(&mut self.inner, &mut inner);
409
410        Box::pin(async move {
411            // Check if session has the required key
412            let session = req.extensions().get::<crate::session::Session>().cloned();
413
414            let user_id = if let Some(ref session) = session {
415                session.get(&session_key).await
416            } else {
417                None
418            };
419
420            if let Some(user_id) = user_id {
421                // Fulfil the RateLimitPrincipal contract so key_strategy =
422                // "authenticated_principal" works without an extra middleware shim.
423                req.extensions_mut()
424                    .insert(crate::security::RateLimitPrincipal(user_id.clone()));
425                // Publish the authenticated principal as the request's current
426                // actor (#1383) and tag the request-scoped log context (#1169)
427                // so handler logs for middleware-authenticated requests carry
428                // `user_id` too, matching the `#[secured]` path.
429                //
430                // Seed the actor only if no stronger/earlier principal is already
431                // set. On a normal session-auth route nothing publishes an actor
432                // before this middleware (the outer `LogContextLayer` only
433                // establishes an empty scope), so `actor().is_none()` is true and
434                // this still seeds. The guard keeps the uniform "first/outermost
435                // resolver wins" rule: if an outer bearer layer or an explicit
436                // `with_actor(...)` scope already resolved a principal, that one
437                // stays. (`set_user_id` for #1169 is independent, stays unconditional.)
438                if crate::current::Current::actor().is_none() {
439                    crate::current::Current::set_actor(user_id.clone());
440                }
441                crate::log::context::set_user_id(user_id);
442                inner.call(req).await
443            } else {
444                let body = crate::error::problem_details_json_string(
445                    StatusCode::UNAUTHORIZED,
446                    "authentication required",
447                    None,
448                    None,
449                    req.extensions()
450                        .get::<crate::middleware::RequestId>()
451                        .map(std::string::ToString::to_string),
452                    Some(req.uri().path().to_owned()),
453                    true,
454                );
455                let response = Response::builder()
456                    .status(StatusCode::UNAUTHORIZED)
457                    .header(http::header::CONTENT_TYPE, "application/problem+json")
458                    .body(ResBody::from(body))
459                    .unwrap_or_default();
460                Ok(response)
461            }
462        })
463    }
464}
465
466// ── Auth configuration ──────────────────────────────────────────
467
468/// Configuration for authentication.
469///
470/// # Defaults
471///
472/// | Field | Default |
473/// |-------|---------|
474/// | `bcrypt_cost` | `12` |
475/// | `session_key` | `"user_id"` |
476#[derive(Debug, Clone, serde::Deserialize)]
477pub struct AuthConfig {
478    /// Bcrypt cost factor for password hashing.
479    #[serde(default = "default_bcrypt_cost")]
480    pub bcrypt_cost: u32,
481
482    /// Session key used to identify authenticated users.
483    #[serde(default = "default_session_key")]
484    pub session_key: String,
485
486    /// OAuth2/OIDC provider configuration by provider key
487    /// (for example: `github`, `google`, `okta`).
488    #[cfg(feature = "oauth2")]
489    #[serde(default)]
490    pub oauth2: OAuth2Config,
491
492    /// Account-linking policy for unknown OAuth2/OIDC identities.
493    ///
494    /// - `create_account` (default): a new local account is created on first sign-in.
495    /// - `require_local_signup_first`: returns an error unless the user already has
496    ///   a local account linked to their provider identity.
497    #[cfg(feature = "oauth2")]
498    #[serde(default)]
499    pub oauth_linking_policy: OAuthLinkingPolicy,
500
501    /// `WebAuthn` / passkey configuration.
502    ///
503    /// Required when using `autumn generate auth --passkeys`. Set in `autumn.toml`:
504    ///
505    /// ```toml
506    /// [auth.webauthn]
507    /// rp_id = "example.com"
508    /// rp_name = "My App"
509    /// rp_origin = "https://example.com"
510    /// ```
511    #[cfg(feature = "webauthn")]
512    #[serde(default)]
513    pub webauthn: WebAuthnConfig,
514
515    /// Account lockout policy for the generated login endpoint.
516    ///
517    /// Protects individual accounts from credential-stuffing attacks by locking
518    /// them after a burst of failed login attempts, even when those attempts
519    /// arrive from rotating source IPs.
520    ///
521    /// Configure in `autumn.toml`:
522    ///
523    /// ```toml
524    /// [auth.lockout]
525    /// enabled = true          # set to false to disable (e.g. when using external policy)
526    /// threshold = 10          # failed attempts before lockout
527    /// window_secs = 60        # sliding window for counting failures
528    /// cooloff_secs = 900      # lock duration in seconds (15 minutes)
529    /// ```
530    ///
531    /// Set `threshold = 0` or `enabled = false` to disable lockout entirely and
532    /// restore pre-lockout behaviour for apps with a stronger external policy.
533    #[serde(default)]
534    pub lockout: LockoutConfig,
535
536    /// Step-up ("sudo mode") authentication configuration.
537    ///
538    /// Controls the global default freshness window for `#[step_up]`-protected
539    /// routes. Individual routes can override the default with
540    /// `#[step_up(max_age = "Nm")]`.
541    ///
542    /// Configure in `autumn.toml`:
543    ///
544    /// ```toml
545    /// [auth.step_up]
546    /// default_max_age_secs = 300  # 5 minutes (default)
547    /// ```
548    #[serde(default)]
549    pub step_up: StepUpConfig,
550
551    /// Active-session tracking and revocation policy (issue #819).
552    ///
553    /// Controls whether credential-changing events (password change, TOTP
554    /// enrollment/disable, `WebAuthn` key add/remove) revoke all *other*
555    /// login sessions (default: on), and how often `last_seen_at` is
556    /// written per session.
557    ///
558    /// Configure in `autumn.toml`:
559    ///
560    /// ```toml
561    /// [auth.sessions]
562    /// revoke_on_credential_change = true
563    /// last_seen_update_secs = 60
564    /// ```
565    #[serde(default)]
566    pub sessions: SessionTrackingConfig,
567
568    /// Password policy: length, weak-password rejection, context-similarity,
569    /// and optional Have I Been Pwned (HIBP) breach checking.
570    ///
571    /// Configure in `autumn.toml`:
572    ///
573    /// ```toml
574    /// [auth.password]
575    /// min_length = 8
576    /// reject_common = true
577    /// breach_check = "off"  # "off" | "fail_open" | "fail_closed"
578    /// ```
579    #[serde(default)]
580    pub password: PasswordConfig,
581
582    /// Persistent "remember-me" login policy (issue #1397).
583    ///
584    /// Controls the rotating, revocable remember-me tokens issued alongside a
585    /// session on login. Each credential is a `(series, token)` pair with the
586    /// token rotated on every use for theft detection.
587    ///
588    /// Configure in `autumn.toml`:
589    ///
590    /// ```toml
591    /// [auth.remember]
592    /// enabled = true             # issue remember cookies on login (default)
593    /// duration_secs = 2592000    # cookie lifetime in seconds (30 days)
594    /// cookie_name = "autumn.remember"
595    /// ```
596    #[serde(default)]
597    pub remember: RememberConfig,
598
599    /// Passwordless magic-link login policy (issue #1737).
600    ///
601    /// Controls the one-time sign-in link lifetime and the per-email re-mint
602    /// cooldown for the routes emitted by `autumn generate auth --magic-link`.
603    ///
604    /// Configure in `autumn.toml`:
605    ///
606    /// ```toml
607    /// [auth.magic_link]
608    /// ttl_minutes = 15          # link lifetime; keep ≤ 15 min for a tight window
609    /// email_cooldown_secs = 60  # per-email re-mint cooldown (email-bomb throttle)
610    /// ```
611    #[serde(default)]
612    pub magic_link: MagicLinkConfig,
613}
614
615/// Account lockout policy configuration.
616///
617/// Read from the `[auth.lockout]` section of `autumn.toml`.
618/// All fields have safe production defaults.
619#[derive(Debug, Clone, serde::Deserialize)]
620pub struct LockoutConfig {
621    /// Whether account lockout is enabled (default: `true`).
622    ///
623    /// Set to `false` to disable lockout globally without removing the columns.
624    #[serde(default = "default_lockout_enabled")]
625    pub enabled: bool,
626
627    /// Number of consecutive failed login attempts before an account is locked
628    /// (default: `10`). Set to `0` to disable lockout.
629    #[serde(default = "default_lockout_threshold")]
630    pub threshold: i32,
631
632    /// Sliding window in seconds over which `threshold` failures trigger lockout
633    /// (default: `60`). Reserved for future per-window counting; current
634    /// implementation counts all failures since the last successful login.
635    #[serde(default = "default_lockout_window_secs")]
636    pub window_secs: u64,
637
638    /// Cool-off period in seconds before a locked account is automatically
639    /// unlocked (default: `900`, i.e. 15 minutes). An account also unlocks
640    /// immediately on the first successful login after cool-off elapses.
641    #[serde(default = "default_lockout_cooloff_secs")]
642    pub cooloff_secs: u64,
643}
644
645const fn default_lockout_enabled() -> bool {
646    true
647}
648
649const fn default_lockout_threshold() -> i32 {
650    10
651}
652
653const fn default_lockout_window_secs() -> u64 {
654    60
655}
656
657const fn default_lockout_cooloff_secs() -> u64 {
658    900
659}
660
661impl Default for LockoutConfig {
662    fn default() -> Self {
663        Self {
664            enabled: default_lockout_enabled(),
665            threshold: default_lockout_threshold(),
666            window_secs: default_lockout_window_secs(),
667            cooloff_secs: default_lockout_cooloff_secs(),
668        }
669    }
670}
671/// Step-up authentication configuration.
672///
673/// Read from the `[auth.step_up]` section of `autumn.toml`.
674/// All fields have safe defaults.
675#[derive(Debug, Clone, serde::Deserialize)]
676pub struct StepUpConfig {
677    /// Maximum age (in seconds) of the `last_strong_auth_at` session claim
678    /// before the user must re-authenticate (default: `300`, i.e. 5 minutes).
679    ///
680    /// Individual routes can override this with
681    /// `#[step_up(max_age = "Nm")]`.
682    #[serde(default = "default_step_up_max_age_secs")]
683    pub default_max_age_secs: u64,
684}
685
686const fn default_step_up_max_age_secs() -> u64 {
687    crate::step_up::DEFAULT_MAX_AGE_SECS
688}
689
690impl Default for StepUpConfig {
691    fn default() -> Self {
692        Self {
693            default_max_age_secs: crate::step_up::DEFAULT_MAX_AGE_SECS,
694        }
695    }
696}
697
698/// Active-session tracking configuration (issue #819).
699///
700/// Read from the `[auth.sessions]` section of `autumn.toml`. Used by the
701/// session-management machinery emitted by `autumn generate auth`: a
702/// persisted row per login session, a device list at `/account/sessions`,
703/// and per-session / bulk revocation.
704///
705/// ```toml
706/// [auth.sessions]
707/// revoke_on_credential_change = true  # default
708/// last_seen_update_secs = 60          # default
709/// ```
710#[derive(Debug, Clone, Copy, serde::Deserialize)]
711pub struct SessionTrackingConfig {
712    /// Revoke all *other* login sessions when credentials change —
713    /// password change/reset, TOTP enrollment or disable, and `WebAuthn`
714    /// key add/remove (default: `true`).
715    ///
716    /// Leave this on unless an external policy handles credential-change
717    /// hygiene: it is the standard response to credential theft.
718    #[serde(default = "default_true_flag")]
719    pub revoke_on_credential_change: bool,
720
721    /// Minimum number of seconds between `last_seen_at` writes for a given
722    /// session (default: `60`).
723    ///
724    /// Bounds write amplification: authenticated requests inside the window
725    /// skip the `UPDATE`, so a busy session costs at most one write per
726    /// window rather than one per request.
727    #[serde(default = "default_last_seen_update_secs")]
728    pub last_seen_update_secs: u64,
729}
730
731const fn default_true_flag() -> bool {
732    true
733}
734
735const fn default_last_seen_update_secs() -> u64 {
736    60
737}
738
739impl Default for SessionTrackingConfig {
740    fn default() -> Self {
741        Self {
742            revoke_on_credential_change: true,
743            last_seen_update_secs: default_last_seen_update_secs(),
744        }
745    }
746}
747
748/// Passwordless magic-link login configuration (issue #1737).
749///
750/// Read from the `[auth.magic_link]` section of `autumn.toml`. Consumed by the
751/// routes emitted by `autumn generate auth --magic-link` via `state.config()`.
752/// All fields have safe production defaults.
753///
754/// ```toml
755/// [auth.magic_link]
756/// ttl_minutes = 15          # default
757/// email_cooldown_secs = 60  # default
758/// ```
759#[derive(Debug, Clone, Copy, serde::Deserialize)]
760pub struct MagicLinkConfig {
761    /// One-time sign-in link lifetime, in minutes (default: `15`).
762    ///
763    /// Keep this at `15` or less: a magic link is a bearer credential, so a
764    /// tight expiry window bounds the blast radius of a leaked link (e.g. via a
765    /// forwarded email or a shared inbox). Raise it only with that tradeoff in
766    /// mind.
767    ///
768    /// Unsigned: a negative `ttl_minutes` in `autumn.toml` fails deserialization
769    /// (a negative TTL would mint already-expired tokens, breaking login).
770    #[serde(default = "default_magic_link_ttl_minutes")]
771    pub ttl_minutes: u64,
772
773    /// Per-email cooldown, in seconds (default: `60`).
774    ///
775    /// `POST /login/magic` skips minting a fresh token when an unexpired,
776    /// unconsumed token was already issued for the account within this window —
777    /// throttling email-bombing a single address even from rotating IPs (the
778    /// per-IP limit is enforced separately by `#[throttle]`).
779    ///
780    /// Unsigned: a negative `email_cooldown_secs` in `autumn.toml` fails
781    /// deserialization. A negative value would push the cooldown window start
782    /// into the future, so the "outstanding token" lookup would never match and
783    /// every request would re-mint and re-send — silently defeating the
784    /// email-bomb throttle.
785    #[serde(default = "default_magic_link_email_cooldown_secs")]
786    pub email_cooldown_secs: u64,
787}
788
789const fn default_magic_link_ttl_minutes() -> u64 {
790    15
791}
792
793const fn default_magic_link_email_cooldown_secs() -> u64 {
794    60
795}
796
797impl Default for MagicLinkConfig {
798    fn default() -> Self {
799        Self {
800            ttl_minutes: default_magic_link_ttl_minutes(),
801            email_cooldown_secs: default_magic_link_email_cooldown_secs(),
802        }
803    }
804}
805
806/// `WebAuthn` / passkey Relying Party configuration.
807///
808/// Read from the `[auth.webauthn]` section of `autumn.toml`.
809#[cfg(feature = "webauthn")]
810#[derive(Debug, Clone, serde::Deserialize)]
811pub struct WebAuthnConfig {
812    /// The Relying Party ID (typically the domain, e.g. `"example.com"`).
813    #[serde(default = "default_rp_id")]
814    pub rp_id: String,
815    /// A human-readable name for the Relying Party shown in authenticator dialogs.
816    #[serde(default = "default_rp_name")]
817    pub rp_name: String,
818    /// The full origin of the Relying Party (e.g. `"https://example.com"`).
819    #[serde(default = "default_rp_origin")]
820    pub rp_origin: String,
821}
822
823#[cfg(feature = "webauthn")]
824impl Default for WebAuthnConfig {
825    fn default() -> Self {
826        Self {
827            rp_id: default_rp_id(),
828            rp_name: default_rp_name(),
829            rp_origin: default_rp_origin(),
830        }
831    }
832}
833
834#[cfg(feature = "webauthn")]
835const fn default_rp_id() -> String {
836    String::new()
837}
838
839#[cfg(feature = "webauthn")]
840fn default_rp_name() -> String {
841    "My App".to_owned()
842}
843
844#[cfg(feature = "webauthn")]
845const fn default_rp_origin() -> String {
846    String::new()
847}
848
849const fn default_bcrypt_cost() -> u32 {
850    DEFAULT_BCRYPT_COST
851}
852
853fn default_session_key() -> String {
854    "user_id".to_owned()
855}
856
857#[cfg(feature = "oauth2")]
858const fn default_provider_scope() -> String {
859    String::new()
860}
861
862#[cfg(feature = "oauth2")]
863const OAUTH_HTTP_TIMEOUT_SECS: u64 = 15;
864
865#[cfg(feature = "oauth2")]
866/// `OAuth2` provider map loaded from `autumn.toml`.
867///
868/// Example:
869///
870/// ```toml
871/// [auth.oauth2.github]
872/// client_id = "..."
873/// client_secret = "..."
874/// authorize_url = "https://github.com/login/oauth/authorize"
875/// token_url = "https://github.com/login/oauth/access_token"
876/// userinfo_url = "https://api.github.com/user"
877/// redirect_uri = "http://localhost:3000/auth/github/callback"
878/// scope = "read:user user:email"
879/// ```
880#[derive(Debug, Clone, Default, serde::Deserialize)]
881pub struct OAuth2Config {
882    /// Dynamic provider table keyed by provider name.
883    #[serde(flatten)]
884    pub providers: HashMap<String, OAuth2ProviderConfig>,
885}
886
887#[cfg(feature = "oauth2")]
888/// A single OAuth2/OIDC provider configuration entry.
889#[derive(Debug, Clone, serde::Deserialize)]
890pub struct OAuth2ProviderConfig {
891    /// The client ID provided by the `OAuth2` identity provider.
892    #[serde(default)]
893    pub client_id: String,
894    /// The client secret provided by the `OAuth2` identity provider.
895    #[serde(default)]
896    pub client_secret: String,
897    /// The authorization endpoint URL where users are redirected to authenticate.
898    #[serde(default)]
899    pub authorize_url: String,
900    /// The token endpoint URL used to exchange an authorization code for tokens.
901    #[serde(default)]
902    pub token_url: String,
903    /// The optional userinfo endpoint URL used to fetch profile details.
904    #[serde(default)]
905    pub userinfo_url: Option<String>,
906    /// The local redirect URI registered with the identity provider (e.g., `http://localhost/auth/callback`).
907    #[serde(default)]
908    pub redirect_uri: String,
909    /// The requested scope string (e.g., `openid profile email`).
910    #[serde(default = "default_provider_scope")]
911    pub scope: String,
912    /// Expected OIDC issuer (`iss`) used for ID token validation.
913    #[serde(default)]
914    pub issuer: Option<String>,
915    /// JWKS endpoint URL used to verify ID token signatures.
916    #[serde(default)]
917    pub jwks_url: Option<String>,
918    /// OIDC discovery base URL (e.g. `https://accounts.google.com`).
919    ///
920    /// When set, the framework appends `/.well-known/openid-configuration` and fetches
921    /// the discovery document to populate `authorize_url`, `token_url`, `userinfo_url`,
922    /// `jwks_url`, and `issuer` automatically. Explicit fields take precedence.
923    #[serde(default)]
924    pub discovery_url: Option<String>,
925}
926
927#[cfg(feature = "oauth2")]
928/// Policy for linking an OAuth2/OIDC identity to a local user account.
929///
930/// Configured under `[auth]` in `autumn.toml`:
931///
932/// ```toml
933/// [auth]
934/// oauth_linking_policy = "create_account"   # default
935/// # or
936/// oauth_linking_policy = "require_local_signup_first"
937/// ```
938#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, Default)]
939#[serde(rename_all = "snake_case")]
940pub enum OAuthLinkingPolicy {
941    /// An unknown `OAuth2` identity automatically creates a new local account.
942    /// This is the default for apps where social login is the primary sign-up path.
943    #[default]
944    CreateAccount,
945    /// An unknown `OAuth2` identity returns a clear error unless the user already
946    /// has a local account (linked separately). Choose this when social login is
947    /// supplemental and you want explicit control over account creation.
948    RequireLocalSignupFirst,
949}
950
951#[cfg(feature = "oauth2")]
952/// Returns a pre-populated [`OAuth2ProviderConfig`] for well-known providers.
953///
954/// `client_id`, `client_secret`, and `redirect_uri` are left empty and must be
955/// supplied by the application from `autumn.toml` or environment variables.
956///
957/// # Supported providers
958///
959/// | Key | Protocol | Notes |
960/// |-----|----------|-------|
961/// | `google` | OIDC | Uses `discovery_url`; scopes: `openid profile email` |
962/// | `github` | `OAuth2` | Userinfo endpoint; no OIDC discovery |
963/// | `microsoft` | OIDC | Uses `discovery_url` (common tenant); scopes: `openid profile email` |
964///
965/// # Examples
966///
967/// ```rust,no_run
968/// use autumn_web::auth::provider_preset;
969/// if let Some(mut preset) = provider_preset("google") {
970///     preset.client_id = std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default();
971///     preset.client_secret = std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default();
972///     preset.redirect_uri = "http://localhost:3000/auth/google/callback".into();
973/// }
974/// ```
975#[must_use]
976pub fn provider_preset(name: &str) -> Option<OAuth2ProviderConfig> {
977    match name {
978        "google" => Some(OAuth2ProviderConfig {
979            client_id: String::new(),
980            client_secret: String::new(),
981            authorize_url: "https://accounts.google.com/o/oauth2/v2/auth".into(),
982            token_url: "https://oauth2.googleapis.com/token".into(),
983            userinfo_url: Some("https://openidconnect.googleapis.com/v1/userinfo".into()),
984            redirect_uri: String::new(),
985            scope: "openid profile email".into(),
986            issuer: Some("https://accounts.google.com".into()),
987            jwks_url: Some("https://www.googleapis.com/oauth2/v3/certs".into()),
988            discovery_url: Some("https://accounts.google.com".into()),
989        }),
990        "github" => Some(OAuth2ProviderConfig {
991            client_id: String::new(),
992            client_secret: String::new(),
993            authorize_url: "https://github.com/login/oauth/authorize".into(),
994            token_url: "https://github.com/login/oauth/access_token".into(),
995            userinfo_url: Some("https://api.github.com/user".into()),
996            redirect_uri: String::new(),
997            scope: "read:user user:email".into(),
998            issuer: None,
999            jwks_url: None,
1000            discovery_url: None,
1001        }),
1002        "microsoft" => Some(OAuth2ProviderConfig {
1003            client_id: String::new(),
1004            client_secret: String::new(),
1005            authorize_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
1006            token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
1007            userinfo_url: None,
1008            redirect_uri: String::new(),
1009            scope: "openid profile email".into(),
1010            // ⚠ The `common` endpoint is a routing alias — ID tokens it issues carry
1011            // a tenant-specific `iss` claim (`https://login.microsoftonline.com/{tid}/v2.0`),
1012            // which will NOT match this issuer and will fail validation.
1013            //
1014            // Single-tenant apps: replace every `common` segment with your tenant ID.
1015            // Multi-tenant apps: use the `organizations` or `consumers` endpoint and
1016            // override `issuer` with the concrete tenant ID after decoding the `tid` claim.
1017            issuer: Some("https://login.microsoftonline.com/common/v2.0".into()),
1018            jwks_url: Some("https://login.microsoftonline.com/common/discovery/v2.0/keys".into()),
1019            discovery_url: Some("https://login.microsoftonline.com/common/v2.0".into()),
1020        }),
1021        _ => None,
1022    }
1023}
1024
1025#[cfg(feature = "oauth2")]
1026/// Query extractor payload for `OAuth2` callback handlers.
1027#[derive(Debug, Clone, Deserialize)]
1028pub struct OAuth2Callback {
1029    /// The authorization code returned by the provider.
1030    pub code: String,
1031    /// The anti-CSRF state token passed during the authorization request.
1032    pub state: String,
1033}
1034
1035#[cfg(feature = "oauth2")]
1036/// Identity information extracted from an OIDC ID token or userinfo endpoint.
1037#[derive(Debug, Clone)]
1038pub struct OidcIdentity {
1039    /// The primary subject identifier (`sub` claim) representing the user.
1040    pub subject: String,
1041    /// The user's email address, if available in the claims.
1042    pub email: Option<String>,
1043    /// The user's full name, if available in the claims.
1044    pub name: Option<String>,
1045    /// The user's preferred username or nickname, if available in the claims.
1046    pub preferred_username: Option<String>,
1047    /// The raw JSON claims extracted from the token or userinfo response.
1048    pub raw_claims: serde_json::Value,
1049}
1050
1051#[cfg(feature = "oauth2")]
1052#[derive(Debug, Deserialize)]
1053struct OAuth2TokenResponse {
1054    access_token: String,
1055    #[allow(dead_code)]
1056    token_type: Option<String>,
1057    id_token: Option<String>,
1058}
1059
1060#[cfg(feature = "oauth2")]
1061/// Build an `OAuth2` authorization URL and persist anti-CSRF state + nonce in session.
1062///
1063/// PKCE (S256) is always enabled: a `code_verifier` is generated, stored in the
1064/// session, and the corresponding `code_challenge` is added to the URL.
1065///
1066/// # Errors
1067///
1068/// Returns an error if `authorize_url` is not a valid URL.
1069pub async fn oauth2_authorize_url(
1070    session: &crate::session::Session,
1071    provider_name: &str,
1072    provider: &OAuth2ProviderConfig,
1073) -> crate::AutumnResult<String> {
1074    use base64::Engine as _;
1075    use sha2::Digest as _;
1076
1077    let state = uuid::Uuid::new_v4().to_string();
1078    let nonce = uuid::Uuid::new_v4().to_string();
1079
1080    // PKCE S256: generate a 32-byte random verifier, base64url-encode it.
1081    let mut verifier_bytes = [0u8; 32];
1082    getrandom::getrandom(&mut verifier_bytes).map_err(|e| {
1083        crate::AutumnError::service_unavailable_msg(format!("pkce rng failed: {e}"))
1084    })?;
1085    let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
1086    // code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
1087    let digest = sha2::Sha256::digest(code_verifier.as_bytes());
1088    let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
1089
1090    session
1091        .insert(format!("oauth2:{provider_name}:state"), state.clone())
1092        .await;
1093    session
1094        .insert(format!("oauth2:{provider_name}:nonce"), nonce.clone())
1095        .await;
1096    session
1097        .insert(
1098            format!("oauth2:{provider_name}:code_verifier"),
1099            code_verifier,
1100        )
1101        .await;
1102
1103    let mut url = Url::parse(&provider.authorize_url)
1104        .map_err(|e| crate::AutumnError::bad_request_msg(format!("invalid authorize_url: {e}")))?;
1105    {
1106        let mut q = url.query_pairs_mut();
1107        q.append_pair("response_type", "code");
1108        q.append_pair("client_id", &provider.client_id);
1109        q.append_pair("redirect_uri", &provider.redirect_uri);
1110        if !provider.scope.trim().is_empty() {
1111            q.append_pair("scope", &provider.scope);
1112        }
1113        q.append_pair("state", &state);
1114        q.append_pair("nonce", &nonce);
1115        q.append_pair("code_challenge", &code_challenge);
1116        q.append_pair("code_challenge_method", "S256");
1117    }
1118    Ok(url.into())
1119}
1120
1121#[cfg(feature = "oauth2")]
1122/// Exchange callback code for tokens, validate state/nonce, and return OIDC identity.
1123///
1124/// On success this method rotates the session ID (preventing session fixation) and
1125/// writes `auth_provider` to the session. It does **not** set the application
1126/// session key — callers are responsible for resolving or creating a local user
1127/// account and then calling `session.insert(session_key, local_user_id)`.
1128///
1129/// The PKCE `code_verifier` is read from the session (stored by
1130/// [`oauth2_authorize_url`]) and included in the token exchange for every
1131/// provider, regardless of whether the provider is confidential or public.
1132///
1133/// # Errors
1134///
1135/// Returns an error when callback state/nonce validation fails, token exchange
1136/// fails, ID token/userinfo payloads are invalid, or identity extraction fails.
1137pub async fn oauth2_finish_login(
1138    session: &crate::session::Session,
1139    provider_name: &str,
1140    provider: &OAuth2ProviderConfig,
1141    callback: &OAuth2Callback,
1142) -> crate::AutumnResult<OidcIdentity> {
1143    validate_callback_state(session, provider_name, callback).await?;
1144    // Retrieve (and consume) the PKCE code_verifier stored during authorize.
1145    let code_verifier = session
1146        .remove(&format!("oauth2:{provider_name}:code_verifier"))
1147        .await
1148        .ok_or_else(|| {
1149            crate::AutumnError::unauthorized_msg("oauth2 code_verifier missing from session")
1150        })?;
1151    let token = exchange_oauth2_token(provider, callback, code_verifier).await?;
1152    let (claims, source) = load_identity_claims(provider, &token).await?;
1153    validate_oidc_nonce(session, provider_name, &claims, source).await?;
1154    let subject = extract_subject(&claims, source)?;
1155    finalize_oauth2_session(session, provider_name, subject, claims).await
1156}
1157
1158#[cfg(feature = "oauth2")]
1159async fn validate_callback_state(
1160    session: &crate::session::Session,
1161    provider_name: &str,
1162    callback: &OAuth2Callback,
1163) -> crate::AutumnResult<()> {
1164    let state_key = format!("oauth2:{provider_name}:state");
1165    // Read without removing so a stray/attacker-controlled callback with a
1166    // wrong state value cannot consume the real state and break the pending
1167    // legitimate redirect.
1168    let expected_state = session.get(&state_key).await.ok_or_else(|| {
1169        crate::AutumnError::unauthorized_msg("oauth2 state missing; restart login")
1170    })?;
1171    if subtle::ConstantTimeEq::ct_eq(expected_state.as_bytes(), callback.state.as_bytes())
1172        .unwrap_u8()
1173        != 1
1174    {
1175        return Err(crate::AutumnError::unauthorized_msg(
1176            "oauth2 state mismatch",
1177        ));
1178    }
1179    // Remove the state only after a successful constant-time match.
1180    session.remove(&state_key).await;
1181    Ok(())
1182}
1183
1184#[cfg(feature = "oauth2")]
1185async fn exchange_oauth2_token(
1186    provider: &OAuth2ProviderConfig,
1187    callback: &OAuth2Callback,
1188    code_verifier: String,
1189) -> crate::AutumnResult<OAuth2TokenResponse> {
1190    // Build the base form fields; PKCE code_verifier is appended.
1191    let form_fields: Vec<(&str, String)> = vec![
1192        ("grant_type", "authorization_code".to_owned()),
1193        ("code", callback.code.clone()),
1194        ("redirect_uri", provider.redirect_uri.clone()),
1195        ("client_id", provider.client_id.clone()),
1196        ("client_secret", provider.client_secret.clone()),
1197        ("code_verifier", code_verifier),
1198    ];
1199    let token_response = oauth_http_client()?
1200        .post(&provider.token_url)
1201        .header(reqwest::header::ACCEPT, "application/json")
1202        .form(&form_fields)
1203        .send()
1204        .await
1205        .map_err(|e| {
1206            crate::AutumnError::service_unavailable_msg(format!("token request failed: {e}"))
1207        })?
1208        .error_for_status()
1209        .map_err(|e| crate::AutumnError::unauthorized_msg(format!("token exchange failed: {e}")))?;
1210
1211    let token_content_type = token_response
1212        .headers()
1213        .get(reqwest::header::CONTENT_TYPE)
1214        .and_then(|v| v.to_str().ok())
1215        .map(str::to_owned);
1216    let token_body = token_response.text().await.map_err(|e| {
1217        crate::AutumnError::bad_request_msg(format!("invalid token response body: {e}"))
1218    })?;
1219    parse_oauth2_token_response(token_content_type.as_deref(), &token_body)
1220}
1221
1222#[cfg(feature = "oauth2")]
1223async fn load_identity_claims(
1224    provider: &OAuth2ProviderConfig,
1225    token: &OAuth2TokenResponse,
1226) -> crate::AutumnResult<(serde_json::Value, IdentitySource)> {
1227    if let Some(id_token) = token.id_token.as_deref() {
1228        return Ok((
1229            validate_and_decode_id_token(id_token, provider).await?,
1230            IdentitySource::IdToken,
1231        ));
1232    }
1233    if let Some(userinfo_url) = &provider.userinfo_url {
1234        let claims = oauth_http_client()?
1235            .get(userinfo_url)
1236            .header(
1237                reqwest::header::USER_AGENT,
1238                concat!("autumn-web/", env!("CARGO_PKG_VERSION")),
1239            )
1240            .bearer_auth(&token.access_token)
1241            .send()
1242            .await
1243            .map_err(|e| {
1244                crate::AutumnError::service_unavailable_msg(format!("userinfo request failed: {e}"))
1245            })?
1246            .error_for_status()
1247            .map_err(|e| crate::AutumnError::unauthorized_msg(format!("userinfo failed: {e}")))?
1248            .json()
1249            .await
1250            .map_err(|e| {
1251                crate::AutumnError::bad_request_msg(format!("invalid userinfo payload: {e}"))
1252            })?;
1253        return Ok((claims, IdentitySource::UserInfo));
1254    }
1255    Err(crate::AutumnError::bad_request_msg(
1256        "provider must return id_token or configure userinfo_url",
1257    ))
1258}
1259
1260#[cfg(feature = "oauth2")]
1261async fn validate_oidc_nonce(
1262    session: &crate::session::Session,
1263    provider_name: &str,
1264    claims: &serde_json::Value,
1265    source: IdentitySource,
1266) -> crate::AutumnResult<()> {
1267    let nonce_key = format!("oauth2:{provider_name}:nonce");
1268    let stored_nonce = session.remove(&nonce_key).await;
1269    if source == IdentitySource::IdToken {
1270        // The nonce MUST be present in the session for ID-token logins.
1271        // A missing nonce (e.g. session was partially cleared) must be
1272        // treated as an error to prevent replay/mix-up attacks.
1273        let expected_nonce = stored_nonce.ok_or_else(|| {
1274            crate::AutumnError::unauthorized_msg("oauth2 nonce missing from session")
1275        })?;
1276        let actual_nonce = claims
1277            .get("nonce")
1278            .and_then(serde_json::Value::as_str)
1279            .ok_or_else(|| crate::AutumnError::unauthorized_msg("missing oidc nonce claim"))?;
1280        if subtle::ConstantTimeEq::ct_eq(expected_nonce.as_bytes(), actual_nonce.as_bytes())
1281            .unwrap_u8()
1282            != 1
1283        {
1284            return Err(crate::AutumnError::unauthorized_msg("oidc nonce mismatch"));
1285        }
1286    }
1287    Ok(())
1288}
1289
1290#[cfg(feature = "oauth2")]
1291async fn finalize_oauth2_session(
1292    session: &crate::session::Session,
1293    provider_name: &str,
1294    subject: String,
1295    claims: serde_json::Value,
1296) -> crate::AutumnResult<OidcIdentity> {
1297    // Write provider metadata only — callers set the application session key
1298    // after resolving or creating the local user record.
1299    session.insert("auth_provider", provider_name).await;
1300    session.rotate_id().await;
1301    Ok(OidcIdentity {
1302        subject,
1303        email: claims
1304            .get("email")
1305            .and_then(serde_json::Value::as_str)
1306            .map(str::to_owned),
1307        name: claims
1308            .get("name")
1309            .and_then(serde_json::Value::as_str)
1310            .map(str::to_owned),
1311        preferred_username: claims
1312            .get("preferred_username")
1313            .and_then(serde_json::Value::as_str)
1314            .map(str::to_owned),
1315        raw_claims: claims,
1316    })
1317}
1318
1319#[cfg(feature = "oauth2")]
1320fn parse_oauth2_token_response(
1321    content_type: Option<&str>,
1322    body: &str,
1323) -> crate::AutumnResult<OAuth2TokenResponse> {
1324    let looks_like_json = content_type.is_some_and(|v| v.contains("application/json"))
1325        || body.trim_start().starts_with('{');
1326    if looks_like_json {
1327        return serde_json::from_str(body).map_err(|e| {
1328            crate::AutumnError::bad_request_msg(format!("invalid json token response: {e}"))
1329        });
1330    }
1331
1332    let mut access_token = None;
1333    let mut token_type = None;
1334    let mut id_token = None;
1335
1336    for (k, v) in url::form_urlencoded::parse(body.as_bytes()) {
1337        match k.as_ref() {
1338            "access_token" => access_token = Some(v.into_owned()),
1339            "token_type" => token_type = Some(v.into_owned()),
1340            "id_token" => id_token = Some(v.into_owned()),
1341            _ => {}
1342        }
1343    }
1344
1345    let access_token = access_token.ok_or_else(|| {
1346        crate::AutumnError::bad_request_msg("token response missing access_token")
1347    })?;
1348
1349    Ok(OAuth2TokenResponse {
1350        access_token,
1351        token_type,
1352        id_token,
1353    })
1354}
1355
1356#[cfg(feature = "oauth2")]
1357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1358enum IdentitySource {
1359    IdToken,
1360    UserInfo,
1361}
1362
1363#[cfg(feature = "oauth2")]
1364fn extract_subject(
1365    claims: &serde_json::Value,
1366    source: IdentitySource,
1367) -> crate::AutumnResult<String> {
1368    if let Some(sub) = claims.get("sub").and_then(serde_json::Value::as_str) {
1369        return Ok(sub.to_owned());
1370    }
1371
1372    if source == IdentitySource::UserInfo {
1373        if let Some(id) = claims.get("id").and_then(serde_json::Value::as_i64) {
1374            return Ok(id.to_string());
1375        }
1376        if let Some(id) = claims.get("id").and_then(serde_json::Value::as_str) {
1377            return Ok(id.to_owned());
1378        }
1379        return Err(crate::AutumnError::bad_request_msg(
1380            "missing identity claim: expected sub or id from userinfo",
1381        ));
1382    }
1383
1384    Err(crate::AutumnError::bad_request_msg("missing sub claim"))
1385}
1386
1387/// Returns the set of signature algorithms a given JWKS key is allowed to
1388/// verify. The set is derived from trusted key material only (the JWK's
1389/// declared `alg`, or failing that its key type), never from the untrusted
1390/// token header. Symmetric algorithms are always rejected: OIDC `id_token`s are
1391/// verified against public JWKS keys, and accepting HS* here would enable
1392/// algorithm-confusion forgeries.
1393#[cfg(feature = "oauth2")]
1394fn jwk_allowed_algorithms(
1395    jwk: &jsonwebtoken::jwk::Jwk,
1396) -> crate::AutumnResult<Vec<jsonwebtoken::Algorithm>> {
1397    use jsonwebtoken::Algorithm;
1398    use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, KeyAlgorithm};
1399
1400    // If the JWKS entry declares an algorithm, it is the only one accepted.
1401    if let Some(key_alg) = jwk.common.key_algorithm {
1402        let alg = match key_alg {
1403            KeyAlgorithm::RS256 => Algorithm::RS256,
1404            KeyAlgorithm::RS384 => Algorithm::RS384,
1405            KeyAlgorithm::RS512 => Algorithm::RS512,
1406            KeyAlgorithm::PS256 => Algorithm::PS256,
1407            KeyAlgorithm::PS384 => Algorithm::PS384,
1408            KeyAlgorithm::PS512 => Algorithm::PS512,
1409            KeyAlgorithm::ES256 => Algorithm::ES256,
1410            KeyAlgorithm::ES384 => Algorithm::ES384,
1411            KeyAlgorithm::EdDSA => Algorithm::EdDSA,
1412            // Symmetric (HS*) and encryption algorithms are never valid for
1413            // verifying an id_token signature against a JWKS document.
1414            other => {
1415                return Err(crate::AutumnError::unauthorized_msg(format!(
1416                    "jwk algorithm {other} not allowed for id_token verification"
1417                )));
1418            }
1419        };
1420        return Ok(vec![alg]);
1421    }
1422
1423    // Otherwise derive the allowed set from the key type. Only asymmetric
1424    // signature algorithms compatible with the key are permitted.
1425    match &jwk.algorithm {
1426        AlgorithmParameters::RSA(_) => Ok(vec![
1427            Algorithm::RS256,
1428            Algorithm::RS384,
1429            Algorithm::RS512,
1430            Algorithm::PS256,
1431            Algorithm::PS384,
1432            Algorithm::PS512,
1433        ]),
1434        AlgorithmParameters::EllipticCurve(params) => match params.curve {
1435            EllipticCurve::P256 => Ok(vec![Algorithm::ES256]),
1436            EllipticCurve::P384 => Ok(vec![Algorithm::ES384]),
1437            ref other => Err(crate::AutumnError::unauthorized_msg(format!(
1438                "unsupported jwk curve {other:?} for id_token verification"
1439            ))),
1440        },
1441        AlgorithmParameters::OctetKeyPair(_) => Ok(vec![Algorithm::EdDSA]),
1442        AlgorithmParameters::OctetKey(_) => Err(crate::AutumnError::unauthorized_msg(
1443            "symmetric jwk not allowed for id_token verification",
1444        )),
1445    }
1446}
1447
1448#[cfg(feature = "oauth2")]
1449async fn validate_and_decode_id_token(
1450    token: &str,
1451    provider: &OAuth2ProviderConfig,
1452) -> crate::AutumnResult<serde_json::Value> {
1453    let issuer = provider
1454        .issuer
1455        .as_deref()
1456        .ok_or_else(|| crate::AutumnError::bad_request_msg("provider.issuer required for oidc"))?;
1457    let jwks_url = provider.jwks_url.as_deref().ok_or_else(|| {
1458        crate::AutumnError::bad_request_msg("provider.jwks_url required for oidc")
1459    })?;
1460
1461    let header = jsonwebtoken::decode_header(token).map_err(|e| {
1462        crate::AutumnError::unauthorized_msg(format!("invalid id_token header: {e}"))
1463    })?;
1464    let kid = header
1465        .kid
1466        .as_deref()
1467        .ok_or_else(|| crate::AutumnError::unauthorized_msg("id_token header missing kid"))?;
1468    let alg = header.alg;
1469
1470    let jwks: JwkSet = oauth_http_client()?
1471        .get(jwks_url)
1472        .send()
1473        .await
1474        .map_err(|e| {
1475            crate::AutumnError::service_unavailable_msg(format!("jwks request failed: {e}"))
1476        })?
1477        .error_for_status()
1478        .map_err(|e| crate::AutumnError::unauthorized_msg(format!("jwks fetch failed: {e}")))?
1479        .json()
1480        .await
1481        .map_err(|e| crate::AutumnError::bad_request_msg(format!("invalid jwks response: {e}")))?;
1482
1483    let jwk = jwks
1484        .keys
1485        .iter()
1486        .find(|k| k.common.key_id.as_deref() == Some(kid))
1487        .ok_or_else(|| crate::AutumnError::unauthorized_msg("no jwk matched id_token kid"))?;
1488    let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk)
1489        .map_err(|e| crate::AutumnError::unauthorized_msg(format!("invalid jwk key: {e}")))?;
1490
1491    // Never trust the token header's `alg` to select the verification
1492    // algorithm (algorithm-confusion defense). Pin the accepted set from the
1493    // matched JWK and reject tokens whose header alg is not in it — in
1494    // particular symmetric (HS*) algorithms, which would otherwise let an
1495    // attacker forge tokens HMAC-signed with the public JWKS material.
1496    let allowed_algs = jwk_allowed_algorithms(jwk)?;
1497    if !allowed_algs.contains(&alg) {
1498        return Err(crate::AutumnError::unauthorized_msg(format!(
1499            "id_token alg {alg:?} not permitted by matching jwk"
1500        )));
1501    }
1502    let mut validation = jsonwebtoken::Validation::new(alg);
1503    validation.algorithms = allowed_algs;
1504    let mut issuers = vec![issuer.to_owned()];
1505    let is_multi_tenant = issuer.contains("/common/")
1506        || issuer.contains("/organizations/")
1507        || issuer.contains("/consumers/");
1508    if let (true, true, Some(payload_b64)) = (
1509        issuer.contains("login.microsoftonline.com"),
1510        is_multi_tenant,
1511        token.split('.').nth(1),
1512    ) {
1513        use base64::Engine as _;
1514        let extract_microsoft_iss = || -> Option<String> {
1515            let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1516                .decode(payload_b64)
1517                .ok()?;
1518            let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
1519            let unverified_iss = claims.get("iss")?.as_str()?;
1520            if unverified_iss.starts_with("https://login.microsoftonline.com/")
1521                && unverified_iss.ends_with("/v2.0")
1522            {
1523                Some(unverified_iss.to_owned())
1524            } else {
1525                None
1526            }
1527        };
1528        if let Some(unverified_iss) = extract_microsoft_iss() {
1529            issuers.push(unverified_iss);
1530        }
1531    }
1532    let issuer_refs: Vec<&str> = issuers.iter().map(String::as_str).collect();
1533    validation.set_issuer(&issuer_refs);
1534    validation.set_audience(std::slice::from_ref(&provider.client_id));
1535    validation.required_spec_claims = ["exp", "iss", "aud", "sub"]
1536        .into_iter()
1537        .map(str::to_owned)
1538        .collect();
1539    validation.validate_exp = true;
1540    validation.validate_nbf = true;
1541
1542    let claims = jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)
1543        .map_err(|e| crate::AutumnError::unauthorized_msg(format!("invalid id_token: {e}")))?;
1544    Ok(claims.claims)
1545}
1546
1547#[cfg(feature = "oauth2")]
1548#[derive(Clone)]
1549pub struct HttpClient {
1550    inner: reqwest::Client,
1551}
1552
1553#[cfg(feature = "oauth2")]
1554pub struct HttpRequestBuilder {
1555    client: reqwest::Client,
1556    builder: reqwest::RequestBuilder,
1557}
1558
1559#[cfg(feature = "oauth2")]
1560#[allow(
1561    clippy::must_use_candidate,
1562    clippy::missing_const_for_fn,
1563    clippy::return_self_not_must_use,
1564    clippy::missing_errors_doc,
1565    clippy::redundant_closure_for_method_calls
1566)]
1567impl HttpClient {
1568    #[must_use]
1569    pub const fn new(inner: reqwest::Client) -> Self {
1570        Self { inner }
1571    }
1572
1573    #[must_use]
1574    pub fn post(&self, url: &str) -> HttpRequestBuilder {
1575        HttpRequestBuilder {
1576            client: self.inner.clone(),
1577            builder: self.inner.post(url),
1578        }
1579    }
1580
1581    #[must_use]
1582    pub fn get(&self, url: &str) -> HttpRequestBuilder {
1583        HttpRequestBuilder {
1584            client: self.inner.clone(),
1585            builder: self.inner.get(url),
1586        }
1587    }
1588}
1589
1590#[cfg(feature = "oauth2")]
1591#[allow(
1592    clippy::must_use_candidate,
1593    clippy::missing_const_for_fn,
1594    clippy::return_self_not_must_use,
1595    clippy::missing_errors_doc,
1596    clippy::redundant_closure_for_method_calls
1597)]
1598impl HttpRequestBuilder {
1599    #[must_use]
1600    pub fn header<K, V>(mut self, key: K, value: V) -> Self
1601    where
1602        reqwest::header::HeaderName: TryFrom<K>,
1603        <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
1604        reqwest::header::HeaderValue: TryFrom<V>,
1605        <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
1606    {
1607        self.builder = self.builder.header(key, value);
1608        self
1609    }
1610
1611    #[must_use]
1612    pub fn bearer_auth<T>(mut self, token: T) -> Self
1613    where
1614        T: std::fmt::Display,
1615    {
1616        self.builder = self.builder.bearer_auth(token);
1617        self
1618    }
1619
1620    #[must_use]
1621    pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
1622        self.builder = self.builder.form(form);
1623        self
1624    }
1625
1626    /// Sends the request through the interceptor chain.
1627    ///
1628    /// # Errors
1629    ///
1630    /// Returns a `reqwest::Error` if building the request, sending the request, or
1631    /// intercepting the call fails.
1632    pub async fn send(self) -> Result<reqwest::Response, reqwest::Error> {
1633        let req = self.builder.build()?;
1634        let interceptors = crate::interceptor::ACTIVE_HTTP_INTERCEPTORS
1635            .try_with(Clone::clone)
1636            .unwrap_or_default();
1637        run_http_chain(req, interceptors, self.client.clone(), 0).await
1638    }
1639}
1640
1641#[cfg(feature = "oauth2")]
1642fn run_http_chain(
1643    req: reqwest::Request,
1644    interceptors: Vec<Arc<dyn crate::interceptor::HttpInterceptor>>,
1645    client: reqwest::Client,
1646    idx: usize,
1647) -> std::pin::Pin<
1648    Box<
1649        dyn std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>
1650            + Send
1651            + 'static,
1652    >,
1653> {
1654    Box::pin(async move {
1655        if idx < interceptors.len() {
1656            let interceptor = interceptors[idx].clone();
1657            let next_interceptors = interceptors.clone();
1658            let next_client = client.clone();
1659            let next_fn = move |r: reqwest::Request| {
1660                run_http_chain(r, next_interceptors.clone(), next_client.clone(), idx + 1)
1661            };
1662            let fut = interceptor.intercept(req, &next_fn);
1663            fut.await
1664        } else {
1665            client.execute(req).await
1666        }
1667    })
1668}
1669
1670#[cfg(feature = "oauth2")]
1671fn oauth_http_client() -> crate::AutumnResult<HttpClient> {
1672    let client = reqwest::Client::builder()
1673        .timeout(Duration::from_secs(OAUTH_HTTP_TIMEOUT_SECS))
1674        .build()
1675        .map_err(|e| {
1676            crate::AutumnError::service_unavailable_msg(format!(
1677                "failed to build oauth http client: {e}"
1678            ))
1679        })?;
1680    Ok(HttpClient::new(client))
1681}
1682
1683impl Default for AuthConfig {
1684    fn default() -> Self {
1685        Self {
1686            bcrypt_cost: default_bcrypt_cost(),
1687            session_key: default_session_key(),
1688            #[cfg(feature = "oauth2")]
1689            oauth2: OAuth2Config::default(),
1690            #[cfg(feature = "oauth2")]
1691            oauth_linking_policy: OAuthLinkingPolicy::default(),
1692            #[cfg(feature = "webauthn")]
1693            webauthn: WebAuthnConfig::default(),
1694            lockout: LockoutConfig::default(),
1695            step_up: StepUpConfig::default(),
1696            sessions: SessionTrackingConfig::default(),
1697            password: PasswordConfig::default(),
1698            remember: RememberConfig::default(),
1699            magic_link: MagicLinkConfig::default(),
1700        }
1701    }
1702}
1703
1704// ─────────────────────────────────────────────────────────────────────────────
1705// API Token Authentication
1706// ─────────────────────────────────────────────────────────────────────────────
1707
1708/// A verified token: the principal it authenticates plus the scopes it grants.
1709///
1710/// Returned by [`ApiTokenStore::verify_scoped`]. The granted `scopes` are flat
1711/// permission strings (e.g. `posts:read`, `posts:write`) and are threaded into
1712/// the policy layer so handlers can authorize on them via
1713/// [`crate::authorization::PolicyContext::has_scope`] and
1714/// `#[secured(scopes = [...])]`.
1715#[derive(Debug, Clone, PartialEq, Eq, Default)]
1716pub struct VerifiedToken {
1717    /// The principal the token authenticates (e.g. `user:42`, `service:ci`).
1718    pub principal_id: String,
1719    /// Flat permission strings granted to this token.
1720    pub scopes: Vec<String>,
1721    /// Human-readable name supplied at mint time; used by [`ApiTokenStore::rotate`]
1722    /// to re-issue the replacement with the same name.
1723    pub name: String,
1724    /// Expiry carried through so [`ApiTokenStore::rotate`] can preserve it on
1725    /// the replacement token instead of issuing a non-expiring one.
1726    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1727}
1728
1729/// Non-secret metadata for an issued token, returned by
1730/// [`ApiTokenStore::list`].
1731///
1732/// **Never** carries the raw token or its hash — listing a principal's tokens
1733/// must not expose anything that could be replayed as a credential.
1734#[derive(Debug, Clone, PartialEq, Eq)]
1735pub struct TokenMetadata {
1736    /// Store-defined identifier (e.g. a `BIGSERIAL` id rendered as a string).
1737    pub id: String,
1738    /// Human-readable name supplied at mint time.
1739    pub name: String,
1740    /// The principal the token authenticates.
1741    pub principal_id: String,
1742    /// Flat permission strings granted to this token.
1743    pub scopes: Vec<String>,
1744    /// When the token was issued.
1745    pub created_at: chrono::DateTime<chrono::Utc>,
1746    /// Optional expiry; `None` means the token never expires.
1747    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1748    /// Last successful authentication, recorded on use.
1749    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
1750    /// Revocation timestamp; `None` means the token is still active.
1751    pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
1752}
1753
1754/// Parameters for minting a scoped token via
1755/// [`ApiTokenStore::issue_scoped`] / [`issue_scoped_api_token`].
1756#[derive(Debug, Clone, Default)]
1757pub struct IssueTokenSpec<'a> {
1758    /// The principal the token authenticates.
1759    pub principal_id: &'a str,
1760    /// Human-readable name (e.g. `ci`, `partner-integration`).
1761    pub name: &'a str,
1762    /// Flat permission strings to grant.
1763    pub scopes: &'a [String],
1764    /// Optional expiry; `None` mints a non-expiring token.
1765    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1766}
1767
1768/// Backend trait for storing and verifying API bearer tokens.
1769///
1770/// Implementations persist only the token hash — the raw token is never stored
1771/// at rest. The default backend for tests is [`InMemoryApiTokenStore`].
1772/// Production deployments should use a database-backed implementation.
1773///
1774/// All methods take `&self`; use interior mutability where write access is
1775/// needed.
1776///
1777/// # Scoped tokens
1778///
1779/// The original three methods (`issue` / `verify` / `revoke`) remain the
1780/// minimal contract. The scoped surface — [`issue_scoped`](Self::issue_scoped),
1781/// [`verify_scoped`](Self::verify_scoped), [`list`](Self::list), and
1782/// [`rotate`](Self::rotate) — is provided with **default implementations** that
1783/// delegate to the original three, so existing `impl ApiTokenStore` blocks keep
1784/// compiling unchanged. The built-in stores override them to carry names,
1785/// scopes, expiry, and `last_used_at`.
1786pub trait ApiTokenStore: Send + Sync + 'static {
1787    /// Issue a new token for `principal_id` and return the raw value.
1788    ///
1789    /// Only the hash is persisted. The raw token must be delivered to the
1790    /// caller immediately — it cannot be recovered later.
1791    fn issue<'a>(
1792        &'a self,
1793        principal_id: &'a str,
1794    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>>;
1795
1796    /// Verify `raw_token` and return its principal ID, or `None` for unknown,
1797    /// revoked, or expired tokens.
1798    fn verify<'a>(
1799        &'a self,
1800        raw_token: &'a str,
1801    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>;
1802
1803    /// Revoke a token so that subsequent requests are rejected.
1804    fn revoke<'a>(
1805        &'a self,
1806        raw_token: &'a str,
1807    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>>;
1808
1809    /// Issue a token carrying a name, scopes, and an optional expiry.
1810    ///
1811    /// The default implementation delegates to [`issue`](Self::issue),
1812    /// dropping the name/scopes/expiry — so legacy stores keep working while
1813    /// returning unscoped tokens. Stores that support scopes override this.
1814    fn issue_scoped<'a>(
1815        &'a self,
1816        spec: IssueTokenSpec<'a>,
1817    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
1818        Box::pin(async move { self.issue(spec.principal_id).await })
1819    }
1820
1821    /// Verify `raw_token` and return the principal plus its granted scopes.
1822    ///
1823    /// The default implementation delegates to [`verify`](Self::verify) and
1824    /// yields an empty scope set, preserving legacy behavior.
1825    fn verify_scoped<'a>(
1826        &'a self,
1827        raw_token: &'a str,
1828    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>> {
1829        Box::pin(async move {
1830            Ok(self
1831                .verify(raw_token)
1832                .await?
1833                .map(|principal_id| VerifiedToken {
1834                    principal_id,
1835                    scopes: Vec::new(),
1836                    name: String::new(),
1837                    expires_at: None,
1838                }))
1839        })
1840    }
1841
1842    /// List non-secret metadata for every token belonging to `principal_id`.
1843    ///
1844    /// The default implementation returns an empty list (opt-in). Listing
1845    /// **never** exposes the raw token or its hash.
1846    fn list<'a>(
1847        &'a self,
1848        principal_id: &'a str,
1849    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>> {
1850        let _ = principal_id;
1851        Box::pin(async move { Ok(Vec::new()) })
1852    }
1853
1854    /// Rotate `raw_token`: revoke it and issue a replacement carrying the same
1855    /// name and scopes, returning the new raw token (or `None` if the token
1856    /// was unknown).
1857    ///
1858    /// The default implementation reads the token's scopes via
1859    /// [`verify_scoped`](Self::verify_scoped), revokes it, then mints a fresh
1860    /// token with the same scopes.
1861    fn rotate<'a>(
1862        &'a self,
1863        raw_token: &'a str,
1864    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>> {
1865        Box::pin(async move {
1866            match self.verify_scoped(raw_token).await? {
1867                Some(vt) => {
1868                    self.revoke(raw_token).await?;
1869                    let scopes = vt.scopes.clone();
1870                    let raw = self
1871                        .issue_scoped(IssueTokenSpec {
1872                            principal_id: &vt.principal_id,
1873                            name: &vt.name,
1874                            scopes: &scopes,
1875                            expires_at: vt.expires_at,
1876                        })
1877                        .await?;
1878                    Ok(Some(raw))
1879                }
1880                None => Ok(None),
1881            }
1882        })
1883    }
1884}
1885
1886/// Compute the SHA-256 hash of a raw API token as a lowercase 64-char hex string.
1887///
1888/// The hash is deterministic: the same input always produces the same output.
1889/// Only the hash is ever stored; the raw token is never persisted.
1890///
1891/// # Examples
1892///
1893/// ```rust
1894/// use autumn_web::auth::hash_api_token;
1895///
1896/// let h = hash_api_token("my_token");
1897/// assert_eq!(h.len(), 64);
1898/// assert_eq!(h, hash_api_token("my_token")); // deterministic
1899/// ```
1900#[must_use]
1901pub fn hash_api_token(raw: &str) -> String {
1902    use sha2::Digest as _;
1903    sha2::Sha256::digest(raw.as_bytes())
1904        .iter()
1905        .fold(String::with_capacity(64), |mut s, b| {
1906            use std::fmt::Write as _;
1907            let _ = write!(s, "{b:02x}");
1908            s
1909        })
1910}
1911
1912/// Generate a 256-bit random raw API token as a lowercase hex string.
1913///
1914/// Uses two UUID v4 values (128 bits each) concatenated for a 64-char result.
1915#[must_use]
1916pub fn generate_raw_token() -> String {
1917    let u1 = uuid::Uuid::new_v4();
1918    let u2 = uuid::Uuid::new_v4();
1919    format!("{}{}", u1.simple(), u2.simple())
1920}
1921
1922/// In-memory API token store for development and testing.
1923///
1924/// Tokens are stored as SHA-256 hashes mapped to principal IDs inside a
1925/// `RwLock`-protected `HashMap`. **Not suitable for production** — state is
1926/// lost on restart and is not shared across processes.
1927///
1928/// # Examples
1929///
1930/// ```rust
1931/// use std::sync::Arc;
1932/// use autumn_web::auth::{ApiTokenStore, InMemoryApiTokenStore};
1933///
1934/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
1935/// let store = Arc::new(InMemoryApiTokenStore::default());
1936/// let token = store.issue("user:1").await.unwrap();
1937/// assert_eq!(store.verify(&token).await.unwrap(), Some("user:1".to_owned()));
1938/// store.revoke(&token).await.unwrap();
1939/// assert_eq!(store.verify(&token).await.unwrap(), None);
1940/// # });
1941/// ```
1942/// A token row held by [`InMemoryApiTokenStore`], keyed by token hash.
1943#[derive(Debug, Clone)]
1944struct StoredToken {
1945    id: u64,
1946    principal_id: String,
1947    name: String,
1948    scopes: Vec<String>,
1949    created_at: chrono::DateTime<chrono::Utc>,
1950    expires_at: Option<chrono::DateTime<chrono::Utc>>,
1951    last_used_at: Option<chrono::DateTime<chrono::Utc>>,
1952    revoked_at: Option<chrono::DateTime<chrono::Utc>>,
1953}
1954
1955#[derive(Clone)]
1956pub struct InMemoryApiTokenStore {
1957    // hash → stored token
1958    tokens: Arc<std::sync::RwLock<std::collections::HashMap<String, StoredToken>>>,
1959    next_id: Arc<std::sync::atomic::AtomicU64>,
1960    clock: Arc<dyn crate::time::ClockSource>,
1961}
1962
1963impl Default for InMemoryApiTokenStore {
1964    fn default() -> Self {
1965        Self {
1966            tokens: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
1967            next_id: Arc::new(std::sync::atomic::AtomicU64::new(1)),
1968            clock: Arc::new(crate::time::SystemClock),
1969        }
1970    }
1971}
1972
1973impl InMemoryApiTokenStore {
1974    /// Replace the clock used to evaluate expiry and stamp `last_used_at`.
1975    ///
1976    /// Defaults to [`crate::time::SystemClock`]; tests pass a
1977    /// [`crate::time::FixedClock`] / [`crate::time::TickingClock`] to make
1978    /// expiry and usage timestamps deterministic.
1979    #[must_use]
1980    pub fn with_clock(mut self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
1981        self.clock = clock;
1982        self
1983    }
1984
1985    /// Seed a known raw token for `principal_id`, returning the store for
1986    /// chaining.
1987    ///
1988    /// The raw value is hashed with [`hash_api_token`] and stored exactly like a
1989    /// minted token, so it resolves through the same [`ApiTokenStore::verify`] /
1990    /// [`ApiTokenStore::verify_scoped`] path as a minted one — there is no second
1991    /// verification code path. This exists for the dev / single-tenant case where
1992    /// the server's bearer token comes from an env var and the operator pastes
1993    /// that same value into their MCP client config. **Not suitable for
1994    /// production** — a database-backed [`ApiTokenStore`] remains the production
1995    /// answer.
1996    ///
1997    /// # Examples
1998    ///
1999    /// ```rust
2000    /// use autumn_web::auth::{ApiTokenStore, InMemoryApiTokenStore};
2001    ///
2002    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
2003    /// let store = InMemoryApiTokenStore::default().with_token("dev-token", "user:dev");
2004    /// assert_eq!(
2005    ///     store.verify("dev-token").await.unwrap(),
2006    ///     Some("user:dev".to_owned())
2007    /// );
2008    /// # });
2009    /// ```
2010    #[must_use]
2011    pub fn with_token(self, raw_token: &str, principal_id: &str) -> Self {
2012        self.with_scoped_token(raw_token, principal_id, &[])
2013    }
2014
2015    /// Seed a known raw token carrying `scopes` for `principal_id`.
2016    ///
2017    /// Like [`Self::with_token`], but grants scopes so the seeded token also
2018    /// satisfies [`ApiTokenStore::verify_scoped`] and the
2019    /// `#[secured(scopes = [...])]` gate. Hashes via [`hash_api_token`] and
2020    /// stores through the same path as a minted token.
2021    #[must_use]
2022    pub fn with_scoped_token(self, raw_token: &str, principal_id: &str, scopes: &[String]) -> Self {
2023        self.store_raw_token(
2024            raw_token,
2025            &IssueTokenSpec {
2026                principal_id,
2027                scopes,
2028                ..Default::default()
2029            },
2030        );
2031        self
2032    }
2033
2034    /// Build a store seeded with the raw token read from environment variable
2035    /// `var`, registered for `principal_id`.
2036    ///
2037    /// The one-liner for a dev / single-tenant MCP server whose bearer token is
2038    /// supplied out-of-band (an env var) and pasted verbatim into the MCP client
2039    /// config. Delegates to [`Self::with_token`], so the seeded token verifies
2040    /// through the same path as a minted one. **Not suitable for production.**
2041    ///
2042    /// # Errors
2043    ///
2044    /// Returns an error when `var` is unset or holds an empty / whitespace-only
2045    /// value — a seeding store with no usable token would silently reject every
2046    /// request, so this fails loudly at startup instead.
2047    pub fn from_env(var: &str, principal_id: &str) -> crate::AutumnResult<Self> {
2048        let raw = std::env::var(var).unwrap_or_default();
2049        if raw.trim().is_empty() {
2050            return Err(crate::AutumnError::internal_server_error_msg(format!(
2051                "InMemoryApiTokenStore::from_env: environment variable `{var}` is unset or empty"
2052            )));
2053        }
2054        Ok(Self::default().with_token(&raw, principal_id))
2055    }
2056
2057    /// Insert a freshly-minted token and return its raw value.
2058    fn insert_token(&self, spec: &IssueTokenSpec<'_>) -> String {
2059        let raw = generate_raw_token();
2060        self.store_raw_token(&raw, spec);
2061        raw
2062    }
2063
2064    /// Store `raw_token` for `spec`, hashing it with [`hash_api_token`] into the
2065    /// same `hash → StoredToken` map that [`Self::resolve_used`] reads.
2066    ///
2067    /// Shared by both the random-mint path ([`Self::insert_token`]) and the
2068    /// seeding builders ([`Self::with_token`] / [`Self::with_scoped_token`]), so
2069    /// a seeded token flows through the exact same verification path as a minted
2070    /// one — there is never a second hashing or lookup scheme.
2071    ///
2072    /// A blank (empty or whitespace-only) `raw_token` is a **safe no-op**: it is
2073    /// skipped with a `warn!` rather than stored, so a config typo on the
2074    /// infallible seeding builders can never mint a `hash("")` credential that a
2075    /// blank `Authorization: Bearer ` header would then satisfy. This mirrors the
2076    /// empty/whitespace rejection [`Self::from_env`] applies (which fails loudly
2077    /// before ever reaching here). Minted tokens ([`Self::insert_token`]) are
2078    /// never blank, so the guard is a no-op on that path.
2079    fn store_raw_token(&self, raw_token: &str, spec: &IssueTokenSpec<'_>) {
2080        if raw_token.trim().is_empty() {
2081            tracing::warn!(
2082                "InMemoryApiTokenStore: ignoring a blank (empty or whitespace-only) seed token; \
2083                 no credential was stored"
2084            );
2085            return;
2086        }
2087        let hash = hash_api_token(raw_token);
2088        let id = self
2089            .next_id
2090            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2091        let stored = StoredToken {
2092            id,
2093            principal_id: spec.principal_id.to_owned(),
2094            name: spec.name.to_owned(),
2095            scopes: spec.scopes.to_vec(),
2096            created_at: self.clock.now(),
2097            expires_at: spec.expires_at,
2098            last_used_at: None,
2099            revoked_at: None,
2100        };
2101        self.tokens
2102            .write()
2103            .expect("api token store lock poisoned")
2104            .insert(hash, stored);
2105    }
2106
2107    /// Resolve a live token by raw value, stamping `last_used_at`.
2108    ///
2109    /// Returns `None` for unknown, revoked, or expired tokens.
2110    fn resolve_used(&self, raw_token: &str) -> Option<VerifiedToken> {
2111        let hash = hash_api_token(raw_token);
2112        let now = self.clock.now();
2113        let mut guard = self.tokens.write().expect("api token store lock poisoned");
2114        let stored = guard.get_mut(&hash)?;
2115        if stored.revoked_at.is_some() || stored.expires_at.is_some_and(|exp| exp <= now) {
2116            return None;
2117        }
2118        stored.last_used_at = Some(now);
2119        let verified = VerifiedToken {
2120            principal_id: stored.principal_id.clone(),
2121            scopes: stored.scopes.clone(),
2122            name: stored.name.clone(),
2123            expires_at: stored.expires_at,
2124        };
2125        drop(guard);
2126        Some(verified)
2127    }
2128}
2129
2130impl ApiTokenStore for InMemoryApiTokenStore {
2131    fn issue<'a>(
2132        &'a self,
2133        principal_id: &'a str,
2134    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2135        Box::pin(async move {
2136            Ok(self.insert_token(&IssueTokenSpec {
2137                principal_id,
2138                ..Default::default()
2139            }))
2140        })
2141    }
2142
2143    fn verify<'a>(
2144        &'a self,
2145        raw_token: &'a str,
2146    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>> {
2147        Box::pin(async move { Ok(self.resolve_used(raw_token).map(|vt| vt.principal_id)) })
2148    }
2149
2150    fn revoke<'a>(
2151        &'a self,
2152        raw_token: &'a str,
2153    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>> {
2154        Box::pin(async move {
2155            let hash = hash_api_token(raw_token);
2156            let now = self.clock.now();
2157            let mut guard = self.tokens.write().expect("api token store lock poisoned");
2158            if let Some(stored) = guard.get_mut(&hash) {
2159                stored.revoked_at.get_or_insert(now);
2160            }
2161            drop(guard);
2162            Ok(())
2163        })
2164    }
2165
2166    fn issue_scoped<'a>(
2167        &'a self,
2168        spec: IssueTokenSpec<'a>,
2169    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2170        Box::pin(async move { Ok(self.insert_token(&spec)) })
2171    }
2172
2173    fn verify_scoped<'a>(
2174        &'a self,
2175        raw_token: &'a str,
2176    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>> {
2177        Box::pin(async move { Ok(self.resolve_used(raw_token)) })
2178    }
2179
2180    fn list<'a>(
2181        &'a self,
2182        principal_id: &'a str,
2183    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>> {
2184        Box::pin(async move {
2185            let mut out: Vec<TokenMetadata> = {
2186                let guard = self.tokens.read().expect("api token store lock poisoned");
2187                guard
2188                    .values()
2189                    .filter(|s| s.principal_id == principal_id)
2190                    .map(|s| TokenMetadata {
2191                        id: s.id.to_string(),
2192                        name: s.name.clone(),
2193                        principal_id: s.principal_id.clone(),
2194                        scopes: s.scopes.clone(),
2195                        created_at: s.created_at,
2196                        expires_at: s.expires_at,
2197                        last_used_at: s.last_used_at,
2198                        revoked_at: s.revoked_at,
2199                    })
2200                    .collect()
2201            };
2202            out.sort_by(|a, b| {
2203                a.id.parse::<u64>()
2204                    .unwrap_or(0)
2205                    .cmp(&b.id.parse().unwrap_or(0))
2206            });
2207            Ok(out)
2208        })
2209    }
2210}
2211
2212/// Issue a new API token for `principal_id` using `store`.
2213///
2214/// Returns the raw token string that must be transmitted to the client once.
2215///
2216/// # Errors
2217///
2218/// Propagates any error from the underlying store.
2219pub async fn issue_api_token(
2220    store: &dyn ApiTokenStore,
2221    principal_id: &str,
2222) -> crate::AutumnResult<String> {
2223    store.issue(principal_id).await
2224}
2225
2226/// Revoke a previously issued API token using `store`.
2227///
2228/// After revocation [`RequireApiToken`] rejects requests presenting this token.
2229///
2230/// # Errors
2231///
2232/// Propagates any error from the underlying store.
2233pub async fn revoke_api_token(
2234    store: &dyn ApiTokenStore,
2235    raw_token: &str,
2236) -> crate::AutumnResult<()> {
2237    store.revoke(raw_token).await
2238}
2239
2240/// Issue a named, scoped API token with an optional expiry using `store`.
2241///
2242/// Returns the raw token string that must be transmitted to the client once.
2243/// The granted scopes flow into the policy layer when the token authenticates
2244/// (see [`crate::authorization::PolicyContext::has_scope`] and
2245/// `#[secured(scopes = [...])]`).
2246///
2247/// # Errors
2248///
2249/// Propagates any error from the underlying store.
2250pub async fn issue_scoped_api_token(
2251    store: &dyn ApiTokenStore,
2252    spec: IssueTokenSpec<'_>,
2253) -> crate::AutumnResult<String> {
2254    store.issue_scoped(spec).await
2255}
2256
2257/// List non-secret metadata for every token belonging to `principal_id`.
2258///
2259/// The returned [`TokenMetadata`] never includes the raw token or its hash, so
2260/// it is safe to surface in a management UI or CLI.
2261///
2262/// # Errors
2263///
2264/// Propagates any error from the underlying store.
2265pub async fn list_api_tokens(
2266    store: &dyn ApiTokenStore,
2267    principal_id: &str,
2268) -> crate::AutumnResult<Vec<TokenMetadata>> {
2269    store.list(principal_id).await
2270}
2271
2272/// Rotate an API token: revoke `raw_token` and mint a replacement carrying the
2273/// same name and scopes.
2274///
2275/// Returns the new raw token, or `None` if `raw_token` was unknown.
2276///
2277/// # Errors
2278///
2279/// Propagates any error from the underlying store.
2280pub async fn rotate_api_token(
2281    store: &dyn ApiTokenStore,
2282    raw_token: &str,
2283) -> crate::AutumnResult<Option<String>> {
2284    store.rotate(raw_token).await
2285}
2286
2287/// Private marker inserted into request extensions by [`RequireApiToken`] after
2288/// a bearer token is successfully verified.
2289#[derive(Clone)]
2290struct ApiTokenPrincipal(String);
2291
2292/// Scopes granted to the authenticating service token, inserted into request
2293/// extensions by [`RequireApiToken`] after a bearer token is verified.
2294///
2295/// Public so policy code and the `#[secured(scopes = [...])]` gate can read the
2296/// granted scopes from request extensions. Absent when the request did not
2297/// authenticate via a scoped token (the empty-scope case).
2298///
2299/// # Examples
2300///
2301/// ```rust,no_run
2302/// use autumn_web::prelude::*;
2303/// use autumn_web::auth::ApiTokenScopes;
2304/// use autumn_web::reexports::axum::extract::Extension;
2305///
2306/// #[get("/whoami")]
2307/// async fn whoami(scopes: Option<Extension<ApiTokenScopes>>) -> String {
2308///     match scopes {
2309///         Some(Extension(ApiTokenScopes(s))) => format!("scopes: {s:?}"),
2310///         None => "no token scopes".to_owned(),
2311///     }
2312/// }
2313/// ```
2314#[derive(Debug, Clone, Default, PartialEq, Eq)]
2315pub struct ApiTokenScopes(pub Vec<String>);
2316
2317/// Extractor that yields the verified principal ID from a bearer-protected route.
2318///
2319/// The principal ID is inserted by [`RequireApiToken`] after verifying the
2320/// `Authorization: Bearer <token>` header. Without `RequireApiToken` on the
2321/// route this extractor returns `401 Unauthorized`.
2322///
2323/// # Examples
2324///
2325/// ```rust,no_run
2326/// use autumn_web::prelude::*;
2327/// use autumn_web::auth::ApiToken;
2328///
2329/// #[get("/whoami")]
2330/// async fn whoami(ApiToken(principal): ApiToken) -> String {
2331///     format!("authenticated as {principal}")
2332/// }
2333/// ```
2334#[derive(Debug, Clone)]
2335pub struct ApiToken(pub String);
2336
2337impl<S> FromRequestParts<S> for ApiToken
2338where
2339    S: Send + Sync,
2340{
2341    type Rejection = AuthRejection;
2342
2343    fn from_request_parts(
2344        parts: &mut Parts,
2345        _state: &S,
2346    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
2347        let principal = parts.extensions.get::<ApiTokenPrincipal>().cloned();
2348        async move { principal.map(|p| Self(p.0)).ok_or(AuthRejection) }
2349    }
2350}
2351
2352/// Tower [`Layer`](tower::Layer) that validates `Authorization: Bearer <token>`
2353/// on every inbound request.
2354///
2355/// On success the verified principal ID is inserted into request extensions
2356/// so handlers can retrieve it via the [`ApiToken`] extractor.
2357/// Requests with a missing, malformed, or revoked token are rejected with
2358/// `401 Unauthorized` using the same Problem Details contract as
2359/// [`AuthRejection`].
2360///
2361/// Also inserts [`crate::security::RateLimitPrincipal`] so that
2362/// `key_strategy = "authenticated_principal"` works out of the box.
2363///
2364/// Composes with [`RequireAuth`] and session middleware without conflict.
2365///
2366/// # Examples
2367///
2368/// ```rust,no_run
2369/// use std::sync::Arc;
2370/// use autumn_web::auth::{InMemoryApiTokenStore, RequireApiToken};
2371/// use autumn_web::reexports::axum::{Router, routing::get};
2372/// use autumn_web::AppState;
2373///
2374/// let store = Arc::new(InMemoryApiTokenStore::default());
2375/// let api_routes = Router::<AppState>::new()
2376///     .route("/data", get(|| async { "ok" }))
2377///     .layer(RequireApiToken::new(store));
2378/// ```
2379#[derive(Clone)]
2380pub struct RequireApiToken {
2381    store: Arc<dyn ApiTokenStore>,
2382}
2383
2384impl RequireApiToken {
2385    /// Create a new [`RequireApiToken`] layer backed by `store`.
2386    ///
2387    /// Accepts any `Arc<S>` where `S: ApiTokenStore`, so callers do not need
2388    /// to explicitly cast to `Arc<dyn ApiTokenStore>`.
2389    #[must_use]
2390    pub fn new<S: ApiTokenStore + 'static>(store: Arc<S>) -> Self {
2391        Self { store }
2392    }
2393}
2394
2395impl<S> tower::Layer<S> for RequireApiToken {
2396    type Service = RequireApiTokenService<S>;
2397
2398    fn layer(&self, inner: S) -> Self::Service {
2399        RequireApiTokenService {
2400            inner,
2401            store: Arc::clone(&self.store),
2402        }
2403    }
2404}
2405
2406/// Tower service produced by [`RequireApiToken`].
2407#[derive(Clone)]
2408pub struct RequireApiTokenService<S> {
2409    inner: S,
2410    store: Arc<dyn ApiTokenStore>,
2411}
2412
2413impl<S, ResBody> tower::Service<axum::extract::Request> for RequireApiTokenService<S>
2414where
2415    S: tower::Service<axum::extract::Request, Response = Response<ResBody>>
2416        + Clone
2417        + Send
2418        + 'static,
2419    S::Future: Send + 'static,
2420    S::Error: Send + 'static,
2421    ResBody: From<String> + Default + Send + 'static,
2422{
2423    type Response = Response<ResBody>;
2424    type Error = S::Error;
2425    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
2426
2427    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2428        self.inner.poll_ready(cx)
2429    }
2430
2431    fn call(&mut self, mut req: axum::extract::Request) -> Self::Future {
2432        let store = Arc::clone(&self.store);
2433        let mut inner = self.inner.clone();
2434        std::mem::swap(&mut self.inner, &mut inner);
2435
2436        Box::pin(async move {
2437            // Parse "Authorization: Bearer <token>"
2438            let raw_token = req
2439                .headers()
2440                .get(http::header::AUTHORIZATION)
2441                .and_then(|v| v.to_str().ok())
2442                .and_then(parse_bearer_token)
2443                .map(str::to_owned);
2444
2445            let Some(raw_token) = raw_token else {
2446                let (request_id, instance) = api_token_problem_context(&req);
2447                return Ok(api_token_unauthorized_response(request_id, instance));
2448            };
2449
2450            match store.verify_scoped(&raw_token).await {
2451                Ok(Some(verified)) => {
2452                    let VerifiedToken {
2453                        principal_id,
2454                        scopes,
2455                        ..
2456                    } = verified;
2457                    // Fulfil the RateLimitPrincipal contract so key_strategy =
2458                    // "authenticated_principal" works without an extra middleware shim.
2459                    req.extensions_mut()
2460                        .insert(crate::security::RateLimitPrincipal(principal_id.clone()));
2461                    // Expose the granted scopes via request extensions (NOT the
2462                    // session — writing to the session would persist a cookie
2463                    // and leak scopes onto later cookie-only requests). The
2464                    // `#[secured(scopes = …)]` gate and `PolicyContext`
2465                    // scope-aware helpers read this extension.
2466                    req.extensions_mut().insert(ApiTokenScopes(scopes));
2467                    // Publish the token's principal as the request's current
2468                    // actor (#1383) so generated repository/audit writes
2469                    // auto-attribute to it.
2470                    //
2471                    // Seed the actor only if no stronger/earlier principal is
2472                    // already set. On a bearer-auth route nothing publishes an
2473                    // actor before this middleware (the outer `LogContextLayer`
2474                    // only establishes an empty scope), so `actor().is_none()` is
2475                    // true and this still seeds. The guard keeps the uniform
2476                    // "first/outermost resolver wins" rule: an explicit
2477                    // `with_actor(...)` scope already in effect is preserved.
2478                    if crate::current::Current::actor().is_none() {
2479                        crate::current::Current::set_actor(principal_id.clone());
2480                    }
2481                    req.extensions_mut().insert(ApiTokenPrincipal(principal_id));
2482                    inner.call(req).await
2483                }
2484                Ok(None) => {
2485                    let (request_id, instance) = api_token_problem_context(&req);
2486                    Ok(api_token_unauthorized_response(request_id, instance))
2487                }
2488                Err(err) => {
2489                    let (request_id, instance) = api_token_problem_context(&req);
2490                    Ok(api_token_error_response(&err, request_id, instance))
2491                }
2492            }
2493        })
2494    }
2495}
2496
2497fn parse_bearer_token(header: &str) -> Option<&str> {
2498    let (scheme, token) = header.split_once(' ')?;
2499    scheme.eq_ignore_ascii_case("Bearer").then_some(token)
2500}
2501
2502/// Build a `401 Unauthorized` response using the standard Problem Details body.
2503fn api_token_unauthorized_response<ResBody: From<String> + Default>(
2504    request_id: Option<String>,
2505    instance: Option<String>,
2506) -> Response<ResBody> {
2507    let body = crate::error::problem_details_json_string(
2508        StatusCode::UNAUTHORIZED,
2509        "authentication required",
2510        None,
2511        None,
2512        request_id,
2513        instance,
2514        true,
2515    );
2516    Response::builder()
2517        .status(StatusCode::UNAUTHORIZED)
2518        .header(http::header::CONTENT_TYPE, "application/problem+json")
2519        .body(ResBody::from(body))
2520        .unwrap_or_default()
2521}
2522
2523/// Build a Problem Details response from the API token store error.
2524fn api_token_error_response<ResBody: From<String> + Default>(
2525    err: &crate::AutumnError,
2526    request_id: Option<String>,
2527    instance: Option<String>,
2528) -> Response<ResBody> {
2529    let status = err.status();
2530    let message = err.to_string();
2531    let body = crate::error::problem_details_json_string(
2532        status,
2533        message.clone(),
2534        None,
2535        None,
2536        request_id,
2537        instance,
2538        true,
2539    );
2540    let mut response = Response::builder()
2541        .status(status)
2542        .header(http::header::CONTENT_TYPE, "application/problem+json")
2543        .body(ResBody::from(body))
2544        .unwrap_or_default();
2545    response
2546        .extensions_mut()
2547        .insert(crate::middleware::AutumnErrorInfo {
2548            status,
2549            message,
2550            details: None,
2551            problem_type: None,
2552            backtrace_string: None,
2553        });
2554    response
2555}
2556
2557fn api_token_problem_context(req: &axum::extract::Request) -> (Option<String>, Option<String>) {
2558    (
2559        req.extensions()
2560            .get::<crate::middleware::RequestId>()
2561            .map(std::string::ToString::to_string),
2562        Some(req.uri().path().to_owned()),
2563    )
2564}
2565
2566// ─────────────────────────────────────────────────────────────────────────────
2567// Diesel-backed API Token Store
2568// ─────────────────────────────────────────────────────────────────────────────
2569
2570/// Embedded Diesel migrations for the `api_tokens` table.
2571///
2572/// Include this in your application's `.migrations()` call so that dev/test
2573/// startup migration checks can create and validate the `api_tokens` table
2574/// alongside your own migrations. In production, `autumn migrate` applies the
2575/// matching framework migration before token commands or `DbApiTokenStore`
2576/// need the table:
2577///
2578/// ```rust,ignore
2579/// use autumn_web::auth::API_TOKEN_MIGRATIONS;
2580///
2581/// #[autumn_web::main]
2582/// async fn main() {
2583///     autumn_web::app()
2584///         .migrations(API_TOKEN_MIGRATIONS)
2585///         .run()
2586///         .await;
2587/// }
2588/// ```
2589#[cfg(feature = "db")]
2590pub const API_TOKEN_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
2591    diesel_migrations::embed_migrations!("migrations");
2592
2593#[cfg(feature = "db")]
2594mod db_store {
2595    use std::future::Future;
2596    use std::pin::Pin;
2597
2598    use std::sync::Arc;
2599
2600    use chrono::{DateTime, NaiveDateTime, Utc};
2601    use diesel::OptionalExtension as _;
2602    use diesel::prelude::*;
2603    use diesel_async::AsyncPgConnection;
2604    use diesel_async::RunQueryDsl;
2605    use diesel_async::pooled_connection::deadpool::Pool;
2606
2607    use super::{
2608        ApiTokenStore, IssueTokenSpec, TokenMetadata, VerifiedToken, generate_raw_token,
2609        hash_api_token,
2610    };
2611    use crate::error::AutumnError;
2612    use crate::time::{ClockSource, SystemClock};
2613
2614    diesel::table! {
2615        api_tokens (id) {
2616            id -> Int8,
2617            token_hash -> Text,
2618            principal_id -> Text,
2619            created_at -> Timestamp,
2620            revoked_at -> Nullable<Timestamp>,
2621            name -> Text,
2622            scopes -> Jsonb,
2623            expires_at -> Nullable<Timestamp>,
2624            last_used_at -> Nullable<Timestamp>,
2625        }
2626    }
2627
2628    #[derive(Insertable)]
2629    #[diesel(table_name = api_tokens)]
2630    struct NewApiToken<'a> {
2631        token_hash: &'a str,
2632        principal_id: &'a str,
2633        name: &'a str,
2634        scopes: serde_json::Value,
2635        expires_at: Option<NaiveDateTime>,
2636    }
2637
2638    /// Row shape returned by [`DbApiTokenStore::list`].
2639    #[derive(Queryable)]
2640    struct TokenRow {
2641        id: i64,
2642        name: String,
2643        principal_id: String,
2644        scopes: serde_json::Value,
2645        created_at: NaiveDateTime,
2646        expires_at: Option<NaiveDateTime>,
2647        last_used_at: Option<NaiveDateTime>,
2648        revoked_at: Option<NaiveDateTime>,
2649    }
2650
2651    const fn to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
2652        DateTime::from_naive_utc_and_offset(naive, Utc)
2653    }
2654
2655    fn scopes_to_json(scopes: &[String]) -> serde_json::Value {
2656        serde_json::Value::Array(
2657            scopes
2658                .iter()
2659                .map(|s| serde_json::Value::String(s.clone()))
2660                .collect(),
2661        )
2662    }
2663
2664    #[must_use]
2665    pub fn scopes_from_json(value: &serde_json::Value) -> Vec<String> {
2666        value
2667            .as_array()
2668            .map(|arr| {
2669                arr.iter()
2670                    .filter_map(|v| v.as_str().map(str::to_owned))
2671                    .collect()
2672            })
2673            .unwrap_or_default()
2674    }
2675
2676    /// Postgres-backed [`ApiTokenStore`].
2677    ///
2678    /// Tokens are hashed at rest (SHA-256) and never stored in plaintext.
2679    /// Suitable for production deployments where token state must survive
2680    /// process restarts and be shared across instances.
2681    ///
2682    /// Carries name, scopes, optional expiry, and `last_used_at` in the managed
2683    /// `api_tokens` table (see [`super::API_TOKEN_MIGRATIONS`]). Expired tokens
2684    /// fail to verify; `last_used_at` is stamped on every successful use.
2685    ///
2686    /// # Setup
2687    ///
2688    /// Pass [`super::API_TOKEN_MIGRATIONS`] to your app builder so dev/test
2689    /// startup migration checks can create and validate the `api_tokens`
2690    /// table automatically. In production, run `autumn migrate`; the CLI
2691    /// applies the matching framework migration explicitly.
2692    ///
2693    /// ```rust,ignore
2694    /// use autumn_web::auth::{API_TOKEN_MIGRATIONS, DbApiTokenStore};
2695    /// use autumn_web::db::Pool;
2696    ///
2697    /// let store = DbApiTokenStore::new(pool.clone());
2698    /// autumn_web::app()
2699    ///     .migrations(API_TOKEN_MIGRATIONS)
2700    ///     .run()
2701    ///     .await;
2702    /// ```
2703    #[derive(Clone)]
2704    pub struct DbApiTokenStore {
2705        pool: Pool<AsyncPgConnection>,
2706        clock: Arc<dyn ClockSource>,
2707    }
2708
2709    impl DbApiTokenStore {
2710        /// Create a [`DbApiTokenStore`] backed by `pool`.
2711        #[must_use]
2712        pub fn new(pool: Pool<AsyncPgConnection>) -> Self {
2713            Self {
2714                pool,
2715                clock: Arc::new(SystemClock),
2716            }
2717        }
2718
2719        /// Replace the clock used to evaluate expiry and stamp `last_used_at`.
2720        ///
2721        /// Defaults to [`SystemClock`]; tests pass a fixed/ticking clock to make
2722        /// expiry and usage timestamps deterministic.
2723        #[must_use]
2724        pub fn with_clock(mut self, clock: Arc<dyn ClockSource>) -> Self {
2725            self.clock = clock;
2726            self
2727        }
2728
2729        async fn conn(
2730            &self,
2731        ) -> crate::AutumnResult<diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>>
2732        {
2733            self.pool
2734                .get()
2735                .await
2736                .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))
2737        }
2738    }
2739
2740    impl ApiTokenStore for DbApiTokenStore {
2741        fn issue<'a>(
2742            &'a self,
2743            principal_id: &'a str,
2744        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2745            self.issue_scoped(IssueTokenSpec {
2746                principal_id,
2747                ..Default::default()
2748            })
2749        }
2750
2751        fn verify<'a>(
2752            &'a self,
2753            raw_token: &'a str,
2754        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>
2755        {
2756            Box::pin(async move {
2757                Ok(self
2758                    .verify_scoped(raw_token)
2759                    .await?
2760                    .map(|vt| vt.principal_id))
2761            })
2762        }
2763
2764        fn revoke<'a>(
2765            &'a self,
2766            raw_token: &'a str,
2767        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>> {
2768            Box::pin(async move {
2769                let hash = hash_api_token(raw_token);
2770                let now = self.clock.now().naive_utc();
2771                let mut conn = self.conn().await?;
2772                diesel::update(api_tokens::table)
2773                    .filter(api_tokens::token_hash.eq(&hash))
2774                    .filter(api_tokens::revoked_at.is_null())
2775                    .set(api_tokens::revoked_at.eq(Some(now)))
2776                    .execute(&mut conn)
2777                    .await
2778                    .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2779                Ok(())
2780            })
2781        }
2782
2783        fn issue_scoped<'a>(
2784            &'a self,
2785            spec: IssueTokenSpec<'a>,
2786        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2787            Box::pin(async move {
2788                let raw = generate_raw_token();
2789                let hash = hash_api_token(&raw);
2790                let mut conn = self.conn().await?;
2791                diesel::insert_into(api_tokens::table)
2792                    .values(NewApiToken {
2793                        token_hash: &hash,
2794                        principal_id: spec.principal_id,
2795                        name: spec.name,
2796                        scopes: scopes_to_json(spec.scopes),
2797                        expires_at: spec.expires_at.map(|dt| dt.naive_utc()),
2798                    })
2799                    .execute(&mut conn)
2800                    .await
2801                    .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2802                Ok(raw)
2803            })
2804        }
2805
2806        fn rotate<'a>(
2807            &'a self,
2808            raw_token: &'a str,
2809        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>
2810        {
2811            Box::pin(async move {
2812                #[derive(diesel::QueryableByName)]
2813                struct CountRow {
2814                    #[diesel(sql_type = diesel::sql_types::BigInt)]
2815                    count: i64,
2816                }
2817
2818                let old_hash = hash_api_token(raw_token);
2819                let new_raw = generate_raw_token();
2820                let new_hash = hash_api_token(&new_raw);
2821                let now = self.clock.now().naive_utc();
2822                let mut conn = self.conn().await?;
2823                // Atomic CTE: revoke the old token and insert a replacement carrying
2824                // the same name/scopes/expiry in a single statement. If the old hash
2825                // is unknown or already revoked the UPDATE returns 0 rows, the INSERT
2826                // is a no-op, and COUNT(*) returns 0 — the caller sees None.
2827                // $3 is the store clock's `now` so the expiry predicate matches the
2828                // same instant used by verify_scoped, avoiding clock-skew races.
2829                let row: CountRow = diesel::sql_query(
2830                    "WITH rotated AS ( \
2831                        UPDATE api_tokens \
2832                        SET revoked_at = $3 \
2833                        WHERE token_hash = $1 AND revoked_at IS NULL \
2834                            AND (expires_at IS NULL OR expires_at > $3) \
2835                        RETURNING principal_id, name, scopes, expires_at \
2836                     ), \
2837                     inserted AS ( \
2838                        INSERT INTO api_tokens (token_hash, principal_id, name, scopes, expires_at) \
2839                        SELECT $2, principal_id, name, scopes, expires_at FROM rotated \
2840                        RETURNING 1 \
2841                     ) \
2842                     SELECT COUNT(*)::bigint AS count FROM inserted",
2843                )
2844                .bind::<diesel::sql_types::Text, _>(&old_hash)
2845                .bind::<diesel::sql_types::Text, _>(&new_hash)
2846                .bind::<diesel::sql_types::Timestamp, _>(now)
2847                .get_result(&mut conn)
2848                .await
2849                .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2850                if row.count == 0 {
2851                    Ok(None)
2852                } else {
2853                    Ok(Some(new_raw))
2854                }
2855            })
2856        }
2857
2858        fn verify_scoped<'a>(
2859            &'a self,
2860            raw_token: &'a str,
2861        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>>
2862        {
2863            Box::pin(async move {
2864                let hash = hash_api_token(raw_token);
2865                let now = self.clock.now().naive_utc();
2866                let mut conn = self.conn().await?;
2867                // Live tokens only: not revoked, and either no expiry or not yet expired.
2868                let row: Option<(
2869                    i64,
2870                    String,
2871                    String,
2872                    Option<NaiveDateTime>,
2873                    serde_json::Value,
2874                )> = api_tokens::table
2875                    .filter(api_tokens::token_hash.eq(&hash))
2876                    .filter(api_tokens::revoked_at.is_null())
2877                    .filter(
2878                        api_tokens::expires_at
2879                            .is_null()
2880                            .or(api_tokens::expires_at.gt(now)),
2881                    )
2882                    .select((
2883                        api_tokens::id,
2884                        api_tokens::principal_id,
2885                        api_tokens::name,
2886                        api_tokens::expires_at,
2887                        api_tokens::scopes,
2888                    ))
2889                    .first(&mut conn)
2890                    .await
2891                    .optional()
2892                    .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2893
2894                let Some((id, principal_id, name, expires_at_naive, scopes_json)) = row else {
2895                    return Ok(None);
2896                };
2897                // Throttled usage stamp: only write last_used_at when it is NULL or
2898                // older than 5 minutes, avoiding a write on every single request.
2899                let threshold = now - chrono::Duration::minutes(5);
2900                let _ = diesel::update(
2901                    api_tokens::table.filter(api_tokens::id.eq(id)).filter(
2902                        api_tokens::last_used_at
2903                            .is_null()
2904                            .or(api_tokens::last_used_at.lt(threshold)),
2905                    ),
2906                )
2907                .set(api_tokens::last_used_at.eq(Some(now)))
2908                .execute(&mut conn)
2909                .await;
2910                Ok(Some(VerifiedToken {
2911                    principal_id,
2912                    scopes: scopes_from_json(&scopes_json),
2913                    name,
2914                    expires_at: expires_at_naive.map(to_utc),
2915                }))
2916            })
2917        }
2918
2919        fn list<'a>(
2920            &'a self,
2921            principal_id: &'a str,
2922        ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>>
2923        {
2924            Box::pin(async move {
2925                let mut conn = self.conn().await?;
2926                let rows: Vec<TokenRow> = api_tokens::table
2927                    .filter(api_tokens::principal_id.eq(principal_id))
2928                    .order(api_tokens::id.asc())
2929                    .select((
2930                        api_tokens::id,
2931                        api_tokens::name,
2932                        api_tokens::principal_id,
2933                        api_tokens::scopes,
2934                        api_tokens::created_at,
2935                        api_tokens::expires_at,
2936                        api_tokens::last_used_at,
2937                        api_tokens::revoked_at,
2938                    ))
2939                    .load(&mut conn)
2940                    .await
2941                    .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2942                Ok(rows
2943                    .into_iter()
2944                    .map(|r| TokenMetadata {
2945                        id: r.id.to_string(),
2946                        name: r.name,
2947                        principal_id: r.principal_id,
2948                        scopes: scopes_from_json(&r.scopes),
2949                        created_at: to_utc(r.created_at),
2950                        expires_at: r.expires_at.map(to_utc),
2951                        last_used_at: r.last_used_at.map(to_utc),
2952                        revoked_at: r.revoked_at.map(to_utc),
2953                    })
2954                    .collect())
2955            })
2956        }
2957    }
2958}
2959
2960#[cfg(feature = "db")]
2961pub use db_store::DbApiTokenStore;
2962/// Convert a JSONB `serde_json::Value` (array of strings) to a flat scope list.
2963///
2964/// Returns an empty `Vec` for non-array values; non-string array elements are
2965/// silently skipped. This is the canonical deserializer for the `scopes` JSONB
2966/// column shared by [`DbApiTokenStore`] and the admin panel.
2967#[cfg(feature = "db")]
2968#[doc(hidden)]
2969pub use db_store::scopes_from_json;
2970
2971#[cfg(test)]
2972mod tests {
2973    use super::*;
2974
2975    /// Build a minimal `AppState` for middleware tests, parameterized only by
2976    /// the auth session key (the sole field these tests vary). Collapses the
2977    /// otherwise-identical struct literal that each test would copy verbatim.
2978    fn test_app_state(auth_session_key: &str) -> crate::state::AppState {
2979        crate::state::AppState {
2980            extensions: std::sync::Arc::new(std::sync::RwLock::new(
2981                std::collections::HashMap::new(),
2982            )),
2983            #[cfg(feature = "db")]
2984            pool: None,
2985            #[cfg(feature = "db")]
2986            replica_pool: None,
2987            #[cfg(feature = "db")]
2988            shards: None,
2989            profile: None,
2990            role: crate::config::ProcessRole::Combined,
2991            started_at: std::time::Instant::now(),
2992            health_detailed: false,
2993            probes: crate::probe::ProbeState::ready_for_test(),
2994            metrics: crate::middleware::MetricsCollector::new(),
2995            log_levels: crate::actuator::LogLevels::new("info"),
2996            task_registry: crate::actuator::TaskRegistry::new(),
2997            job_registry: crate::actuator::JobRegistry::new(),
2998            config_props: crate::actuator::ConfigProperties::default(),
2999            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
3000            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
3001            #[cfg(feature = "ws")]
3002            channels: crate::channels::Channels::new(32),
3003            #[cfg(feature = "presence")]
3004            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
3005            #[cfg(feature = "ws")]
3006            shutdown: tokio_util::sync::CancellationToken::new(),
3007            policy_registry: crate::authorization::PolicyRegistry::default(),
3008            forbidden_response: crate::authorization::ForbiddenResponse::default(),
3009            auth_session_key: auth_session_key.to_owned(),
3010            shared_cache: None,
3011            clock: std::sync::Arc::new(crate::time::SystemClock),
3012            app_id: crate::state::AppState::next_app_id(),
3013        }
3014    }
3015
3016    #[tokio::test]
3017    async fn hash_and_verify_password() {
3018        let hash = hash_password("test_password").await.unwrap();
3019        assert!(hash.starts_with("$2b$"));
3020        assert!(verify_password("test_password", &hash).await.unwrap());
3021        assert!(!verify_password("wrong_password", &hash).await.unwrap());
3022    }
3023
3024    #[tokio::test]
3025    async fn verify_invalid_hash_returns_false() {
3026        let result = verify_password("test", "not-a-valid-hash").await;
3027        assert!(result.is_ok());
3028        assert!(!result.unwrap());
3029    }
3030
3031    #[tokio::test]
3032    async fn verify_password_rejects_invalid_hash_format_safely() {
3033        // Test short hash
3034        let result = verify_password("test", "short").await;
3035        assert!(result.is_ok());
3036        assert!(!result.unwrap());
3037
3038        // Test hash with correct length but not starting with $
3039        let bad_prefix = "a".repeat(60);
3040        let result = verify_password("test", &bad_prefix).await;
3041        assert!(result.is_ok());
3042        assert!(!result.unwrap());
3043
3044        // Test hash with incorrect length but starting with $
3045        let bad_length = "$2b$12$short";
3046        let result = verify_password("test", bad_length).await;
3047        assert!(result.is_ok());
3048        assert!(!result.unwrap());
3049    }
3050
3051    #[test]
3052    fn auth_config_defaults() {
3053        let config = AuthConfig::default();
3054        assert_eq!(config.bcrypt_cost, 12);
3055        assert_eq!(config.session_key, "user_id");
3056        #[cfg(feature = "oauth2")]
3057        assert!(config.oauth2.providers.is_empty());
3058    }
3059
3060    /// Issue #819 — credential-changing events revoke other sessions by
3061    /// default, and `last_seen_at` writes are throttled to one per minute.
3062    #[test]
3063    fn session_tracking_config_defaults_to_revoke_on_credential_change() {
3064        let config = AuthConfig::default();
3065        assert!(config.sessions.revoke_on_credential_change);
3066        assert_eq!(config.sessions.last_seen_update_secs, 60);
3067    }
3068
3069    /// `[auth.sessions]` can be disabled / tuned from `autumn.toml`.
3070    #[test]
3071    fn session_tracking_config_deserializes_from_toml() {
3072        let cfg: crate::config::AutumnConfig = toml::from_str(
3073            r"
3074            [auth.sessions]
3075            revoke_on_credential_change = false
3076            last_seen_update_secs = 5
3077            ",
3078        )
3079        .expect("config must parse");
3080        assert!(!cfg.auth.sessions.revoke_on_credential_change);
3081        assert_eq!(cfg.auth.sessions.last_seen_update_secs, 5);
3082    }
3083
3084    #[cfg(feature = "oauth2")]
3085    #[test]
3086    fn oauth2_config_deserializes_provider_tables() {
3087        let cfg: crate::config::AutumnConfig = toml::from_str(
3088            r#"
3089            [auth.oauth2.github]
3090            client_id = "cid"
3091            client_secret = "secret"
3092            authorize_url = "https://github.com/login/oauth/authorize"
3093            token_url = "https://github.com/login/oauth/access_token"
3094            redirect_uri = "http://localhost:3000/auth/github/callback"
3095            "#,
3096        )
3097        .unwrap();
3098        let provider = cfg.auth.oauth2.providers.get("github").unwrap();
3099        assert_eq!(provider.client_id, "cid");
3100        assert_eq!(provider.scope, "");
3101        assert!(provider.issuer.is_none());
3102        assert!(provider.jwks_url.is_none());
3103    }
3104
3105    #[cfg(feature = "oauth2")]
3106    #[tokio::test]
3107    async fn oauth2_authorize_url_sets_state_and_nonce() {
3108        let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3109        let provider = OAuth2ProviderConfig {
3110            client_id: "cid".into(),
3111            client_secret: "secret".into(),
3112            authorize_url: "https://idp.example/authorize".into(),
3113            token_url: "https://idp.example/token".into(),
3114            userinfo_url: None,
3115            redirect_uri: "http://localhost:3000/callback".into(),
3116            scope: "openid profile".into(),
3117            issuer: None,
3118            jwks_url: None,
3119            discovery_url: None,
3120        };
3121        let url = oauth2_authorize_url(&session, "github", &provider)
3122            .await
3123            .unwrap();
3124        assert!(url.contains("response_type=code"));
3125        assert!(session.get("oauth2:github:state").await.is_some());
3126        assert!(session.get("oauth2:github:nonce").await.is_some());
3127    }
3128
3129    #[cfg(feature = "oauth2")]
3130    #[tokio::test]
3131    async fn oauth2_authorize_url_omits_scope_when_empty() {
3132        let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3133        let provider = OAuth2ProviderConfig {
3134            client_id: "cid".into(),
3135            client_secret: "secret".into(),
3136            authorize_url: "https://idp.example/authorize".into(),
3137            token_url: "https://idp.example/token".into(),
3138            userinfo_url: None,
3139            redirect_uri: "http://localhost:3000/callback".into(),
3140            scope: String::new(),
3141            issuer: None,
3142            jwks_url: None,
3143            discovery_url: None,
3144        };
3145        let url = oauth2_authorize_url(&session, "github", &provider)
3146            .await
3147            .unwrap();
3148        assert!(!url.contains("scope="));
3149    }
3150
3151    #[cfg(feature = "oauth2")]
3152    #[tokio::test]
3153    async fn validate_id_token_requires_oidc_metadata() {
3154        let provider = OAuth2ProviderConfig {
3155            client_id: "cid".into(),
3156            client_secret: "secret".into(),
3157            authorize_url: "https://idp.example/authorize".into(),
3158            token_url: "https://idp.example/token".into(),
3159            userinfo_url: None,
3160            redirect_uri: "http://localhost:3000/callback".into(),
3161            scope: "openid profile".into(),
3162            issuer: None,
3163            jwks_url: None,
3164            discovery_url: None,
3165        };
3166        let err = validate_and_decode_id_token("bad.token.value", &provider)
3167            .await
3168            .unwrap_err();
3169        assert_eq!(err.to_string(), "provider.issuer required for oidc");
3170    }
3171
3172    /// RFC 7515 appendix A.2 example RSA public key as a JWK, optionally with
3173    /// a declared `alg`.
3174    #[cfg(feature = "oauth2")]
3175    fn rsa_test_jwk_json(key_algorithm: Option<&str>) -> serde_json::Value {
3176        let mut jwk = serde_json::json!({
3177            "kty": "RSA",
3178            "kid": "test-kid",
3179            "n": "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp\
3180                  -Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOW\
3181                  vG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUs\
3182                  LA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9k\
3183                  G4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2\
3184                  PAcDTW9gb54h4FRWyuXpoQ",
3185            "e": "AQAB"
3186        });
3187        if let Some(alg) = key_algorithm {
3188            jwk["alg"] = serde_json::json!(alg);
3189        }
3190        jwk
3191    }
3192
3193    #[cfg(feature = "oauth2")]
3194    fn rsa_test_jwk(key_algorithm: Option<&str>) -> jsonwebtoken::jwk::Jwk {
3195        serde_json::from_value(rsa_test_jwk_json(key_algorithm)).unwrap()
3196    }
3197
3198    #[cfg(feature = "oauth2")]
3199    #[test]
3200    fn jwk_allowed_algorithms_pins_declared_algorithm() {
3201        let algs = jwk_allowed_algorithms(&rsa_test_jwk(Some("RS256"))).unwrap();
3202        assert_eq!(algs, vec![jsonwebtoken::Algorithm::RS256]);
3203    }
3204
3205    #[cfg(feature = "oauth2")]
3206    #[test]
3207    fn jwk_allowed_algorithms_rejects_symmetric_declared_algorithm() {
3208        let err = jwk_allowed_algorithms(&rsa_test_jwk(Some("HS256"))).unwrap_err();
3209        assert!(
3210            err.to_string().contains("not allowed"),
3211            "expected symmetric alg rejection, got: {err}"
3212        );
3213    }
3214
3215    #[cfg(feature = "oauth2")]
3216    #[test]
3217    fn jwk_allowed_algorithms_derives_asymmetric_set_from_key_type() {
3218        let algs = jwk_allowed_algorithms(&rsa_test_jwk(None)).unwrap();
3219        assert!(algs.contains(&jsonwebtoken::Algorithm::RS256));
3220        assert!(algs.contains(&jsonwebtoken::Algorithm::PS256));
3221        assert!(!algs.contains(&jsonwebtoken::Algorithm::HS256));
3222        assert!(!algs.contains(&jsonwebtoken::Algorithm::HS384));
3223        assert!(!algs.contains(&jsonwebtoken::Algorithm::HS512));
3224    }
3225
3226    #[cfg(feature = "oauth2")]
3227    #[test]
3228    fn jwk_allowed_algorithms_rejects_symmetric_octet_key() {
3229        let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(serde_json::json!({
3230            "kty": "oct",
3231            "kid": "sym-kid",
3232            "k": "c2VjcmV0"
3233        }))
3234        .unwrap();
3235        let err = jwk_allowed_algorithms(&jwk).unwrap_err();
3236        assert!(
3237            err.to_string().contains("symmetric jwk not allowed"),
3238            "expected symmetric jwk rejection, got: {err}"
3239        );
3240    }
3241
3242    /// Serves a fixed JSON body over plain HTTP/1.1 on a random localhost
3243    /// port; returns the URL to fetch it from.
3244    #[cfg(feature = "oauth2")]
3245    async fn spawn_jwks_stub(body: String) -> String {
3246        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3247        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3248        let addr = listener.local_addr().unwrap();
3249        tokio::spawn(async move {
3250            while let Ok((mut stream, _)) = listener.accept().await {
3251                let mut buf = [0u8; 4096];
3252                let _ = stream.read(&mut buf).await;
3253                let response = format!(
3254                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3255                     content-length: {}\r\nconnection: close\r\n\r\n{body}",
3256                    body.len()
3257                );
3258                let _ = stream.write_all(response.as_bytes()).await;
3259                let _ = stream.shutdown().await;
3260            }
3261        });
3262        format!("http://{addr}/jwks")
3263    }
3264
3265    #[cfg(feature = "oauth2")]
3266    fn oidc_test_provider(jwks_url: String) -> OAuth2ProviderConfig {
3267        OAuth2ProviderConfig {
3268            client_id: "cid".into(),
3269            client_secret: "secret".into(),
3270            authorize_url: "https://idp.example/authorize".into(),
3271            token_url: "https://idp.example/token".into(),
3272            userinfo_url: None,
3273            redirect_uri: "http://localhost:3000/callback".into(),
3274            scope: "openid".into(),
3275            issuer: Some("https://idp.example".into()),
3276            jwks_url: Some(jwks_url),
3277            discovery_url: None,
3278        }
3279    }
3280
3281    #[cfg(feature = "oauth2")]
3282    fn unix_now_secs() -> u64 {
3283        std::time::SystemTime::now()
3284            .duration_since(std::time::UNIX_EPOCH)
3285            .unwrap()
3286            .as_secs()
3287    }
3288
3289    /// Algorithm-confusion attack: the JWKS serves an RSA public key, and the
3290    /// attacker forges an HS256 token (HMAC keyed with material derivable
3291    /// from the public key). The token must be rejected because its header
3292    /// alg is not in the set pinned from the JWK — before any signature
3293    /// check is even attempted.
3294    #[cfg(feature = "oauth2")]
3295    #[tokio::test]
3296    async fn validate_id_token_rejects_hs256_against_rsa_jwks_key() {
3297        let jwks_body = serde_json::json!({ "keys": [rsa_test_jwk_json(None)] }).to_string();
3298        let provider = oidc_test_provider(spawn_jwks_stub(jwks_body).await);
3299
3300        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
3301        header.kid = Some("test-kid".into());
3302        let claims = serde_json::json!({
3303            "sub": "attacker-controlled",
3304            "iss": "https://idp.example",
3305            "aud": "cid",
3306            "exp": unix_now_secs() + 3600,
3307        });
3308        let token = jsonwebtoken::encode(
3309            &header,
3310            &claims,
3311            &jsonwebtoken::EncodingKey::from_secret(b"guessed-public-key-material"),
3312        )
3313        .unwrap();
3314
3315        let err = validate_and_decode_id_token(&token, &provider)
3316            .await
3317            .unwrap_err();
3318        assert!(
3319            err.to_string().contains("not permitted by matching jwk"),
3320            "expected algorithm pinning rejection, got: {err}"
3321        );
3322    }
3323
3324    /// A token whose header declares `alg: none` must be rejected outright.
3325    #[cfg(feature = "oauth2")]
3326    #[tokio::test]
3327    async fn validate_id_token_rejects_alg_none() {
3328        use base64::Engine as _;
3329        let b64 = |v: &serde_json::Value| {
3330            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
3331        };
3332        let header = b64(&serde_json::json!({"alg": "none", "kid": "test-kid", "typ": "JWT"}));
3333        let payload = b64(&serde_json::json!({
3334            "sub": "attacker-controlled",
3335            "iss": "https://idp.example",
3336            "aud": "cid",
3337            "exp": unix_now_secs() + 3600,
3338        }));
3339        let token = format!("{header}.{payload}.");
3340
3341        // Unreachable jwks_url: the token must be rejected before any fetch.
3342        let provider = oidc_test_provider("http://127.0.0.1:9/jwks".into());
3343        let err = validate_and_decode_id_token(&token, &provider)
3344            .await
3345            .unwrap_err();
3346        assert!(
3347            err.to_string().contains("invalid id_token header"),
3348            "expected header rejection for alg=none, got: {err}"
3349        );
3350    }
3351
3352    #[cfg(feature = "oauth2")]
3353    #[test]
3354    fn parse_oauth2_token_response_supports_form_encoded_payload() {
3355        let token = parse_oauth2_token_response(
3356            Some("application/x-www-form-urlencoded"),
3357            "access_token=abc123&token_type=bearer&id_token=xyz789&extra_field=ignored",
3358        )
3359        .unwrap();
3360        assert_eq!(token.access_token, "abc123");
3361        assert_eq!(token.token_type.as_deref(), Some("bearer"));
3362        assert_eq!(token.id_token.as_deref(), Some("xyz789"));
3363    }
3364
3365    #[cfg(feature = "oauth2")]
3366    #[test]
3367    fn parse_oauth2_token_response_fails_without_access_token() {
3368        let err = parse_oauth2_token_response(
3369            Some("application/x-www-form-urlencoded"),
3370            "token_type=bearer&id_token=xyz789",
3371        )
3372        .unwrap_err();
3373        assert_eq!(err.to_string(), "token response missing access_token");
3374    }
3375
3376    #[cfg(feature = "oauth2")]
3377    #[test]
3378    fn extract_subject_allows_userinfo_id_fallback() {
3379        let claims = serde_json::json!({ "id": 42 });
3380        let subject = extract_subject(&claims, IdentitySource::UserInfo).unwrap();
3381        assert_eq!(subject, "42");
3382    }
3383
3384    #[cfg(feature = "oauth2")]
3385    #[tokio::test]
3386    async fn validate_callback_state_preserves_state_on_mismatch() {
3387        // An attacker hitting the callback with a wrong state must NOT
3388        // consume the real state stored in the session; the legitimate
3389        // provider redirect must still succeed.
3390        let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3391        session
3392            .insert("oauth2:github:state".to_owned(), "real-state".to_owned())
3393            .await;
3394        let bad_callback = OAuth2Callback {
3395            code: "c".into(),
3396            state: "wrong-state".into(),
3397        };
3398        let err = validate_callback_state(&session, "github", &bad_callback)
3399            .await
3400            .unwrap_err();
3401        assert!(err.to_string().contains("state mismatch"));
3402        // Real state must still be present after the failed attempt.
3403        assert_eq!(
3404            session.get("oauth2:github:state").await.as_deref(),
3405            Some("real-state")
3406        );
3407    }
3408
3409    #[cfg(feature = "oauth2")]
3410    #[tokio::test]
3411    async fn validate_oidc_nonce_rejects_missing_nonce_for_id_token() {
3412        // ID-token logins must fail when there is no stored nonce (e.g.,
3413        // session was partially cleared or forged).
3414        let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3415        // No nonce key inserted — simulates a cleared / missing session.
3416        let claims = serde_json::json!({ "nonce": "any" });
3417        let err = validate_oidc_nonce(&session, "github", &claims, IdentitySource::IdToken)
3418            .await
3419            .unwrap_err();
3420        assert!(err.to_string().contains("nonce missing from session"));
3421    }
3422
3423    #[cfg(feature = "oauth2")]
3424    #[test]
3425    fn extract_subject_requires_sub_for_id_token() {
3426        let claims = serde_json::json!({ "id": "abc" });
3427        let err = extract_subject(&claims, IdentitySource::IdToken).unwrap_err();
3428        assert_eq!(err.to_string(), "missing sub claim");
3429    }
3430
3431    #[test]
3432    fn auth_rejection_is_401() {
3433        let rejection = AuthRejection;
3434        let response = rejection.into_response();
3435        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3436    }
3437
3438    #[test]
3439    fn auth_rejection_display() {
3440        assert_eq!(AuthRejection.to_string(), "authentication required");
3441    }
3442
3443    #[tokio::test]
3444    async fn auth_extractor_returns_401_when_no_user() {
3445        use axum::Router;
3446        use axum::body::Body;
3447        use axum::routing::get;
3448        use tower::ServiceExt;
3449
3450        #[derive(Clone)]
3451        struct TestUser {
3452            name: String,
3453        }
3454
3455        async fn handler(Auth(user): Auth<TestUser>) -> String {
3456            user.name
3457        }
3458
3459        let state = test_app_state("user_id");
3460
3461        let app = Router::new().route("/", get(handler)).with_state(state);
3462
3463        let response = app
3464            .oneshot(
3465                http::Request::builder()
3466                    .uri("/")
3467                    .body(Body::empty())
3468                    .unwrap(),
3469            )
3470            .await
3471            .unwrap();
3472
3473        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3474    }
3475
3476    #[tokio::test]
3477    async fn auth_extractor_returns_user_when_present() {
3478        use axum::Router;
3479        use axum::body::Body;
3480        use axum::routing::get;
3481        use tower::ServiceExt;
3482
3483        #[derive(Clone)]
3484        struct TestUser {
3485            name: String,
3486        }
3487
3488        async fn handler(Auth(user): Auth<TestUser>) -> String {
3489            user.name
3490        }
3491
3492        let state = test_app_state("user_id");
3493
3494        // Middleware that inserts a user into extensions
3495        let app = Router::new()
3496            .route("/", get(handler))
3497            .layer(axum::middleware::from_fn(
3498                |mut req: axum::extract::Request, next: axum::middleware::Next| async move {
3499                    req.extensions_mut().insert(TestUser {
3500                        name: "alice".into(),
3501                    });
3502                    next.run(req).await
3503                },
3504            ))
3505            .with_state(state);
3506
3507        let response = app
3508            .oneshot(
3509                http::Request::builder()
3510                    .uri("/")
3511                    .body(Body::empty())
3512                    .unwrap(),
3513            )
3514            .await
3515            .unwrap();
3516
3517        assert_eq!(response.status(), StatusCode::OK);
3518        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3519            .await
3520            .unwrap();
3521        assert_eq!(std::str::from_utf8(&body).unwrap(), "alice");
3522    }
3523
3524    #[tokio::test]
3525    async fn require_auth_rejects_unauthenticated() {
3526        use axum::Router;
3527        use axum::body::Body;
3528        use axum::routing::get;
3529        use tower::ServiceExt;
3530
3531        use crate::session::{MemoryStore, SessionConfig, SessionLayer};
3532
3533        let state = test_app_state("user_id");
3534
3535        let app = Router::new()
3536            .route("/protected", get(|| async { "secret" }))
3537            .layer(RequireAuth::new("user_id"))
3538            .layer(SessionLayer::new(
3539                MemoryStore::new(),
3540                SessionConfig::default(),
3541            ))
3542            .with_state(state);
3543
3544        let response = app
3545            .oneshot(
3546                http::Request::builder()
3547                    .uri("/protected")
3548                    .body(Body::empty())
3549                    .unwrap(),
3550            )
3551            .await
3552            .unwrap();
3553
3554        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3555    }
3556
3557    // ── __check_secured tests ────────────────────────────────
3558
3559    #[tokio::test]
3560    async fn check_secured_rejects_unauthenticated() {
3561        let session =
3562            crate::session::Session::new_for_test(String::new(), std::collections::HashMap::new());
3563        let result = __check_secured(&session, &[]).await;
3564        assert!(result.is_err());
3565        let err = result.unwrap_err();
3566        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
3567        assert_eq!(err.to_string(), "authentication required");
3568    }
3569
3570    #[tokio::test]
3571    async fn check_secured_allows_authenticated() {
3572        let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3573        let session = crate::session::Session::new_for_test("sess".into(), data);
3574        let result = __check_secured(&session, &[]).await;
3575        assert!(result.is_ok());
3576    }
3577
3578    #[tokio::test]
3579    async fn check_secured_rejects_wrong_role() {
3580        let data = std::collections::HashMap::from([
3581            ("user_id".into(), "42".into()),
3582            ("role".into(), "viewer".into()),
3583        ]);
3584        let session = crate::session::Session::new_for_test("sess".into(), data);
3585        let result = __check_secured(&session, &["admin"]).await;
3586        assert!(result.is_err());
3587        let err = result.unwrap_err();
3588        assert_eq!(err.status(), StatusCode::FORBIDDEN);
3589        assert_eq!(err.to_string(), "insufficient permissions");
3590    }
3591
3592    #[tokio::test]
3593    async fn check_secured_allows_matching_role() {
3594        let data = std::collections::HashMap::from([
3595            ("user_id".into(), "42".into()),
3596            ("role".into(), "admin".into()),
3597        ]);
3598        let session = crate::session::Session::new_for_test("sess".into(), data);
3599        let result = __check_secured(&session, &["admin"]).await;
3600        assert!(result.is_ok());
3601    }
3602
3603    #[tokio::test]
3604    async fn check_secured_allows_any_of_multiple_roles() {
3605        let data = std::collections::HashMap::from([
3606            ("user_id".into(), "42".into()),
3607            ("role".into(), "editor".into()),
3608        ]);
3609        let session = crate::session::Session::new_for_test("sess".into(), data);
3610        let result = __check_secured(&session, &["admin", "editor"]).await;
3611        assert!(result.is_ok());
3612    }
3613
3614    #[tokio::test]
3615    async fn check_secured_seeds_actor_when_none_established() {
3616        // On a normal single-auth `#[secured]` route nothing publishes an actor
3617        // before the role check (the outer `LogContextLayer` establishes only an
3618        // empty scope), so the resolved session user becomes the ambient actor
3619        // and versioned writes attribute to them (#1383).
3620        crate::current::scope_request(async {
3621            assert_eq!(crate::current::Current::actor(), None);
3622            let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3623            let session = crate::session::Session::new_for_test("sess".into(), data);
3624            let result = __check_secured_with_key(&session, "user_id", &[]).await;
3625            assert!(result.is_ok());
3626            assert_eq!(crate::current::Current::actor(), Some("42".to_owned()));
3627        })
3628        .await;
3629    }
3630
3631    #[tokio::test]
3632    async fn check_secured_preserves_already_established_actor() {
3633        // The flagged clobber (#1383): a route that combines `RequireApiToken`
3634        // (bearer, OUTER) with `#[secured]` and *also* carries a session cookie.
3635        // The bearer middleware has already published the token principal by the
3636        // time this inner role check runs; resolving the session user here must
3637        // NOT overwrite the stronger, earlier principal, so versioned writes stay
3638        // attributed to the token principal rather than the cookie user.
3639        crate::current::scope_request(async {
3640            crate::current::Current::set_actor("token-principal".to_owned());
3641            let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3642            let session = crate::session::Session::new_for_test("sess".into(), data);
3643            let result = __check_secured_with_key(&session, "user_id", &[]).await;
3644            // The session still authenticates/authorizes normally...
3645            assert!(result.is_ok());
3646            // ...but the already-established principal wins as the ambient actor.
3647            assert_eq!(
3648                crate::current::Current::actor(),
3649                Some("token-principal".to_owned())
3650            );
3651        })
3652        .await;
3653    }
3654
3655    // ── #[secured] macro integration tests ──────────────────────
3656
3657    #[tokio::test]
3658    async fn secured_macro_rejects_unauthenticated() {
3659        use axum::Router;
3660        use axum::body::Body;
3661        use axum::routing::get;
3662        use tower::ServiceExt;
3663
3664        use crate::session::{MemoryStore, SessionConfig, SessionLayer};
3665
3666        #[autumn_macros::secured]
3667        async fn protected_handler() -> crate::AutumnResult<&'static str> {
3668            Ok("secret")
3669        }
3670
3671        let state = test_app_state("user_id");
3672
3673        let app = Router::new()
3674            .route("/", get(protected_handler))
3675            .layer(SessionLayer::new(
3676                MemoryStore::new(),
3677                SessionConfig::default(),
3678            ))
3679            .with_state(state);
3680
3681        let response = app
3682            .oneshot(
3683                http::Request::builder()
3684                    .uri("/")
3685                    .body(Body::empty())
3686                    .unwrap(),
3687            )
3688            .await
3689            .unwrap();
3690
3691        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3692    }
3693
3694    #[tokio::test]
3695    async fn secured_macro_allows_authenticated() {
3696        use axum::Router;
3697        use axum::body::Body;
3698        use axum::routing::get;
3699        use http::header::COOKIE;
3700        use tower::ServiceExt;
3701
3702        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3703
3704        #[autumn_macros::secured]
3705        async fn protected_handler() -> crate::AutumnResult<&'static str> {
3706            Ok("secret")
3707        }
3708
3709        let store = MemoryStore::new();
3710        store
3711            .save(
3712                "sess1",
3713                std::collections::HashMap::from([("user_id".into(), "42".into())]),
3714            )
3715            .await
3716            .unwrap();
3717
3718        let state = test_app_state("user_id");
3719
3720        let app = Router::new()
3721            .route("/", get(protected_handler))
3722            .layer(SessionLayer::new(store, SessionConfig::default()))
3723            .with_state(state);
3724
3725        let response = app
3726            .oneshot(
3727                http::Request::builder()
3728                    .uri("/")
3729                    .header(COOKIE, "autumn.sid=sess1")
3730                    .body(Body::empty())
3731                    .unwrap(),
3732            )
3733            .await
3734            .unwrap();
3735
3736        assert_eq!(response.status(), StatusCode::OK);
3737        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3738            .await
3739            .unwrap();
3740        assert_eq!(std::str::from_utf8(&body).unwrap(), "secret");
3741    }
3742
3743    #[tokio::test]
3744    async fn secured_macro_honors_configured_auth_session_key() {
3745        use axum::Router;
3746        use axum::body::Body;
3747        use axum::routing::get;
3748        use http::header::COOKIE;
3749        use tower::ServiceExt;
3750
3751        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3752
3753        #[autumn_macros::secured]
3754        async fn account_handler() -> crate::AutumnResult<&'static str> {
3755            Ok("account")
3756        }
3757
3758        let store = MemoryStore::new();
3759        store
3760            .save(
3761                "sess1",
3762                std::collections::HashMap::from([
3763                    ("uid".into(), "42".into()),
3764                    ("account_id".into(), "42".into()),
3765                ]),
3766            )
3767            .await
3768            .unwrap();
3769
3770        let state = test_app_state("uid");
3771
3772        let app = Router::new()
3773            .route("/account", get(account_handler))
3774            .layer(SessionLayer::new(store, SessionConfig::default()))
3775            .with_state(state);
3776
3777        let response = app
3778            .oneshot(
3779                http::Request::builder()
3780                    .uri("/account")
3781                    .header(COOKIE, "autumn.sid=sess1")
3782                    .body(Body::empty())
3783                    .unwrap(),
3784            )
3785            .await
3786            .unwrap();
3787
3788        assert_eq!(response.status(), StatusCode::OK);
3789        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3790            .await
3791            .unwrap();
3792        assert_eq!(std::str::from_utf8(&body).unwrap(), "account");
3793    }
3794
3795    #[tokio::test]
3796    async fn secured_macro_with_role_rejects_wrong_role() {
3797        use axum::Router;
3798        use axum::body::Body;
3799        use axum::routing::get;
3800        use http::header::COOKIE;
3801        use tower::ServiceExt;
3802
3803        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3804
3805        #[autumn_macros::secured("admin")]
3806        async fn admin_only() -> crate::AutumnResult<&'static str> {
3807            Ok("admin area")
3808        }
3809
3810        let store = MemoryStore::new();
3811        store
3812            .save(
3813                "sess1",
3814                std::collections::HashMap::from([
3815                    ("user_id".into(), "42".into()),
3816                    ("role".into(), "viewer".into()),
3817                ]),
3818            )
3819            .await
3820            .unwrap();
3821
3822        let state = test_app_state("user_id");
3823
3824        let app = Router::new()
3825            .route("/", get(admin_only))
3826            .layer(SessionLayer::new(store, SessionConfig::default()))
3827            .with_state(state);
3828
3829        let response = app
3830            .oneshot(
3831                http::Request::builder()
3832                    .uri("/")
3833                    .header(COOKIE, "autumn.sid=sess1")
3834                    .body(Body::empty())
3835                    .unwrap(),
3836            )
3837            .await
3838            .unwrap();
3839
3840        assert_eq!(response.status(), StatusCode::FORBIDDEN);
3841    }
3842
3843    #[tokio::test]
3844    async fn secured_macro_with_multiple_roles_allows_match() {
3845        use axum::Router;
3846        use axum::body::Body;
3847        use axum::routing::get;
3848        use http::header::COOKIE;
3849        use tower::ServiceExt;
3850
3851        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3852
3853        #[autumn_macros::secured("admin", "editor")]
3854        async fn content_handler() -> crate::AutumnResult<&'static str> {
3855            Ok("content")
3856        }
3857
3858        let store = MemoryStore::new();
3859        store
3860            .save(
3861                "sess1",
3862                std::collections::HashMap::from([
3863                    ("user_id".into(), "42".into()),
3864                    ("role".into(), "editor".into()),
3865                ]),
3866            )
3867            .await
3868            .unwrap();
3869
3870        let state = test_app_state("user_id");
3871
3872        let app = Router::new()
3873            .route("/", get(content_handler))
3874            .layer(SessionLayer::new(store, SessionConfig::default()))
3875            .with_state(state);
3876
3877        let response = app
3878            .oneshot(
3879                http::Request::builder()
3880                    .uri("/")
3881                    .header(COOKIE, "autumn.sid=sess1")
3882                    .body(Body::empty())
3883                    .unwrap(),
3884            )
3885            .await
3886            .unwrap();
3887
3888        assert_eq!(response.status(), StatusCode::OK);
3889        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3890            .await
3891            .unwrap();
3892        assert_eq!(std::str::from_utf8(&body).unwrap(), "content");
3893    }
3894
3895    #[tokio::test]
3896    async fn require_auth_allows_authenticated() {
3897        use axum::Router;
3898        use axum::body::Body;
3899        use axum::routing::get;
3900        use http::header::COOKIE;
3901        use tower::ServiceExt;
3902
3903        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3904
3905        let store = MemoryStore::new();
3906        // Pre-populate a session with user_id
3907        let mut session_data = std::collections::HashMap::new();
3908        session_data.insert("user_id".into(), "42".into());
3909        store.save("valid-session", session_data).await.unwrap();
3910
3911        let state = test_app_state("user_id");
3912
3913        let app = Router::new()
3914            .route("/protected", get(|| async { "secret" }))
3915            .layer(RequireAuth::new("user_id"))
3916            .layer(SessionLayer::new(store, SessionConfig::default()))
3917            .with_state(state);
3918
3919        let response = app
3920            .oneshot(
3921                http::Request::builder()
3922                    .uri("/protected")
3923                    .header(COOKIE, "autumn.sid=valid-session")
3924                    .body(Body::empty())
3925                    .unwrap(),
3926            )
3927            .await
3928            .unwrap();
3929
3930        assert_eq!(response.status(), StatusCode::OK);
3931        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3932            .await
3933            .unwrap();
3934        assert_eq!(std::str::from_utf8(&body).unwrap(), "secret");
3935    }
3936
3937    #[tokio::test]
3938    async fn require_auth_sets_rate_limit_principal() {
3939        use axum::Router;
3940        use axum::body::Body;
3941        use axum::routing::get;
3942        use http::header::COOKIE;
3943        use tower::ServiceExt;
3944
3945        use crate::security::RateLimitPrincipal;
3946        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3947
3948        async fn handler(
3949            axum::Extension(principal): axum::Extension<RateLimitPrincipal>,
3950        ) -> String {
3951            principal.0
3952        }
3953
3954        let store = MemoryStore::new();
3955        let mut session_data = std::collections::HashMap::new();
3956        session_data.insert("user_id".into(), "42".into());
3957        store.save("valid-session", session_data).await.unwrap();
3958
3959        let state = test_app_state("user_id");
3960
3961        let app = Router::new()
3962            .route("/protected", get(handler))
3963            .layer(RequireAuth::new("user_id"))
3964            .layer(SessionLayer::new(store, SessionConfig::default()))
3965            .with_state(state);
3966
3967        let response = app
3968            .oneshot(
3969                http::Request::builder()
3970                    .uri("/protected")
3971                    .header(COOKIE, "autumn.sid=valid-session")
3972                    .body(Body::empty())
3973                    .unwrap(),
3974            )
3975            .await
3976            .unwrap();
3977
3978        assert_eq!(response.status(), StatusCode::OK);
3979        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3980            .await
3981            .unwrap();
3982        assert_eq!(std::str::from_utf8(&body).unwrap(), "42");
3983    }
3984
3985    #[tokio::test]
3986    async fn require_auth_rate_limits_by_session_principal() {
3987        // Verifies end-to-end: RequireAuth (outer) sets RateLimitPrincipal so a
3988        // route-scoped RateLimitLayer (inner) keys on the principal, not the IP.
3989        // Layer composition:  Session → RequireAuth → RateLimitLayer → handler
3990        use axum::Router;
3991        use axum::body::Body;
3992        use axum::routing::get;
3993        use http::header::COOKIE;
3994        use tower::ServiceExt;
3995
3996        use crate::security::{KeyStrategy, RateLimitConfig, RateLimitLayer};
3997        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3998
3999        let session_store = MemoryStore::new();
4000        let mut data_a = std::collections::HashMap::new();
4001        data_a.insert("user_id".into(), "user-1".into());
4002        session_store.save("sess-a", data_a).await.unwrap();
4003        let mut data_b = std::collections::HashMap::new();
4004        data_b.insert("user_id".into(), "user-2".into());
4005        session_store.save("sess-b", data_b).await.unwrap();
4006
4007        let rl_config = RateLimitConfig {
4008            enabled: true,
4009            requests_per_second: 0.1,
4010            burst: 1,
4011            key_strategy: KeyStrategy::AuthenticatedPrincipal,
4012            ..Default::default()
4013        };
4014
4015        let state = test_app_state("user_id");
4016
4017        let app = Router::new()
4018            .route("/protected", get(|| async { "ok" }))
4019            .layer(RateLimitLayer::from_config(&rl_config)) // inner — reads principal
4020            .layer(RequireAuth::new("user_id")) // outer — sets RateLimitPrincipal
4021            .layer(SessionLayer::new(session_store, SessionConfig::default()))
4022            .with_state(state);
4023
4024        // user-1 first request: allowed (1 token in bucket).
4025        let r = app
4026            .clone()
4027            .oneshot(
4028                http::Request::builder()
4029                    .uri("/protected")
4030                    .header(COOKIE, "autumn.sid=sess-a")
4031                    .body(Body::empty())
4032                    .unwrap(),
4033            )
4034            .await
4035            .unwrap();
4036        assert_eq!(r.status(), StatusCode::OK);
4037
4038        // user-1 second request: bucket exhausted → 429.
4039        let r = app
4040            .clone()
4041            .oneshot(
4042                http::Request::builder()
4043                    .uri("/protected")
4044                    .header(COOKIE, "autumn.sid=sess-a")
4045                    .body(Body::empty())
4046                    .unwrap(),
4047            )
4048            .await
4049            .unwrap();
4050        assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
4051
4052        // user-2 first request: separate bucket → allowed.
4053        let r = app
4054            .clone()
4055            .oneshot(
4056                http::Request::builder()
4057                    .uri("/protected")
4058                    .header(COOKIE, "autumn.sid=sess-b")
4059                    .body(Body::empty())
4060                    .unwrap(),
4061            )
4062            .await
4063            .unwrap();
4064        assert_eq!(r.status(), StatusCode::OK);
4065    }
4066
4067    #[tokio::test]
4068    async fn require_auth_poll_ready_propagates() {
4069        use std::task::{Context, Poll};
4070        use tower::{Layer, Service};
4071
4072        #[derive(Clone)]
4073        struct MockService {
4074            ready: bool,
4075            poll_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
4076        }
4077
4078        impl Service<axum::extract::Request> for MockService {
4079            type Response = axum::response::Response;
4080            type Error = std::convert::Infallible;
4081            type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
4082
4083            fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
4084                self.poll_count
4085                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4086                if self.ready {
4087                    Poll::Ready(Ok(()))
4088                } else {
4089                    Poll::Pending
4090                }
4091            }
4092
4093            fn call(&mut self, _req: axum::extract::Request) -> Self::Future {
4094                std::future::ready(Ok(axum::response::Response::new(axum::body::Body::empty())))
4095            }
4096        }
4097
4098        let layer = RequireAuth::new("user_id");
4099        let poll_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4100        let mock_service = MockService {
4101            ready: false,
4102            poll_count: poll_count.clone(),
4103        };
4104        let mut service = layer.layer(mock_service);
4105
4106        let waker = futures::task::noop_waker();
4107        let mut cx = Context::from_waker(&waker);
4108
4109        // When inner is not ready, RequireAuthService should not be ready
4110        let poll = service.poll_ready(&mut cx);
4111        assert!(poll.is_pending());
4112        assert_eq!(poll_count.load(std::sync::atomic::Ordering::SeqCst), 1);
4113
4114        // When inner is ready, RequireAuthService should be ready
4115        let mock_service_ready = MockService {
4116            ready: true,
4117            poll_count: poll_count.clone(),
4118        };
4119        let mut service_ready = layer.layer(mock_service_ready);
4120        let poll_ready = service_ready.poll_ready(&mut cx);
4121        assert!(poll_ready.is_ready());
4122        assert_eq!(poll_count.load(std::sync::atomic::Ordering::SeqCst), 2);
4123    }
4124
4125    #[tokio::test]
4126    async fn auth_rejection_into_response() {
4127        let rejection = AuthRejection;
4128        let response = rejection.into_response();
4129        assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED);
4130        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4131            .await
4132            .unwrap();
4133        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4134        assert_eq!(json["status"], 401);
4135        assert_eq!(json["detail"], "authentication required");
4136        assert_eq!(json["code"], "autumn.unauthorized");
4137    }
4138
4139    #[test]
4140    fn test_auth_config_defaults() {
4141        let config = AuthConfig::default();
4142        assert_eq!(config.bcrypt_cost, DEFAULT_BCRYPT_COST);
4143        assert_eq!(config.session_key, "user_id");
4144    }
4145
4146    #[tokio::test]
4147    async fn test_hash_password() {
4148        let test_input = uuid::Uuid::new_v4().to_string();
4149
4150        // Test hashing
4151        let hash = super::hash_password(&test_input)
4152            .await
4153            .expect("Failed to hash password");
4154        assert!(hash.starts_with("$2b$"));
4155
4156        // Test verification with correct password
4157        let is_valid = super::verify_password(&test_input, &hash)
4158            .await
4159            .expect("Failed to verify password");
4160        assert!(is_valid, "Password should be verified successfully");
4161
4162        // Test verification with incorrect password
4163        let is_invalid = super::verify_password(&uuid::Uuid::new_v4().to_string(), &hash)
4164            .await
4165            .expect("Failed to verify wrong password");
4166        assert!(!is_invalid, "Wrong password should not be verified");
4167    }
4168
4169    #[tokio::test]
4170    async fn test_hash_password_empty() {
4171        let test_input = String::new();
4172        let hash = super::hash_password(&test_input)
4173            .await
4174            .expect("Failed to hash empty password");
4175        assert!(hash.starts_with("$2b$"));
4176
4177        let is_valid = super::verify_password(&test_input, &hash)
4178            .await
4179            .expect("Failed to verify empty password");
4180        assert!(is_valid, "Empty password should be verified successfully");
4181    }
4182
4183    #[tokio::test]
4184    async fn test_hash_password_long() {
4185        // bcrypt truncates after 72 bytes. We just want to ensure it doesn't crash.
4186        let test_input = "a".repeat(100);
4187        let hash = super::hash_password(&test_input)
4188            .await
4189            .expect("Failed to hash long password");
4190        assert!(hash.starts_with("$2b$"));
4191
4192        let is_valid = super::verify_password(&test_input, &hash)
4193            .await
4194            .expect("Failed to verify long password");
4195        assert!(is_valid, "Long password should be verified successfully");
4196    }
4197
4198    #[tokio::test]
4199    async fn test_hash_password_unicode() {
4200        // Test with non-ascii characters
4201        let test_input = format!("{}🚀my_secrët_passwörd🔑", uuid::Uuid::new_v4());
4202        let hash = super::hash_password(&test_input)
4203            .await
4204            .expect("Failed to hash unicode password");
4205        assert!(hash.starts_with("$2b$"));
4206
4207        let is_valid = super::verify_password(&test_input, &hash)
4208            .await
4209            .expect("Failed to verify unicode password");
4210        assert!(is_valid, "Unicode password should be verified successfully");
4211    }
4212
4213    #[tokio::test]
4214    async fn test_verify_password_invalid_hash() {
4215        // Ensure that providing invalid hashes doesn't crash or cause issues, but returns an error/false
4216        let test_input = uuid::Uuid::new_v4().to_string();
4217
4218        // Invalid prefix
4219        let result = super::verify_password(&test_input, "invalid_hash_string").await;
4220        assert!(result.is_err() || !result.unwrap());
4221
4222        // Truncated hash
4223        let result2 = super::verify_password(&test_input, "$2b$04$").await;
4224        assert!(result2.is_err() || !result2.unwrap());
4225    }
4226}
4227
4228// ── HttpRequestBuilder interceptor task-local scope tests ────────────────────
4229
4230#[cfg(feature = "oauth2")]
4231#[cfg(test)]
4232mod http_interceptor_task_local_tests {
4233    use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor, HttpInterceptorFuture};
4234    use std::sync::{
4235        Arc,
4236        atomic::{AtomicBool, Ordering},
4237    };
4238
4239    struct FlagInterceptor {
4240        fired: Arc<AtomicBool>,
4241    }
4242
4243    impl HttpInterceptor for FlagInterceptor {
4244        fn intercept<'a>(
4245            &'a self,
4246            req: reqwest::Request,
4247            next: &'a dyn Fn(reqwest::Request) -> HttpInterceptorFuture<'a>,
4248        ) -> HttpInterceptorFuture<'a> {
4249            self.fired.store(true, Ordering::SeqCst);
4250            // Delegate to next so the caller gets a real (likely connection-refused)
4251            // error back — we discard it in the test with `let _ = ...`.
4252            next(req)
4253        }
4254    }
4255
4256    /// Proves the task-local scope contract: when `ACTIVE_HTTP_INTERCEPTORS` is
4257    /// set via `.scope()` (as `run_one_off_task_mode` must do), the interceptor
4258    /// fires on every `HttpRequestBuilder::send` call within that scope.
4259    #[tokio::test]
4260    async fn http_request_builder_send_fires_interceptor_inside_scope() {
4261        let fired = Arc::new(AtomicBool::new(false));
4262        let interceptor: Arc<dyn HttpInterceptor> = Arc::new(FlagInterceptor {
4263            fired: Arc::clone(&fired),
4264        });
4265
4266        let client = reqwest::Client::new();
4267        let http_client = super::HttpClient::new(client);
4268
4269        ACTIVE_HTTP_INTERCEPTORS
4270            .scope(vec![interceptor], async {
4271                let _ = http_client
4272                    .get("http://127.0.0.1:54321/noreply")
4273                    .send()
4274                    .await;
4275            })
4276            .await;
4277
4278        assert!(
4279            fired.load(Ordering::SeqCst),
4280            "interceptor must fire when ACTIVE_HTTP_INTERCEPTORS scope is established"
4281        );
4282    }
4283
4284    /// Proves the regression: without a scope, the interceptor is silently
4285    /// skipped. The fix in `run_one_off_task_mode` wraps the task handler in
4286    /// `ACTIVE_HTTP_INTERCEPTORS.scope(...)` so that registered interceptors are
4287    /// always active during task execution.
4288    #[tokio::test]
4289    async fn http_request_builder_send_skips_interceptor_outside_scope() {
4290        let fired = Arc::new(AtomicBool::new(false));
4291        let _interceptor: Arc<dyn HttpInterceptor> = Arc::new(FlagInterceptor {
4292            fired: Arc::clone(&fired),
4293        });
4294
4295        // Intentionally do NOT establish a scope — simulating pre-fix task mode.
4296        let client = reqwest::Client::new();
4297        let http_client = super::HttpClient::new(client);
4298        let _ = http_client
4299            .get("http://127.0.0.1:54321/noreply")
4300            .send()
4301            .await;
4302
4303        assert!(
4304            !fired.load(Ordering::SeqCst),
4305            "interceptor must NOT fire when ACTIVE_HTTP_INTERCEPTORS scope is absent"
4306        );
4307    }
4308}
4309
4310#[cfg(test)]
4311mod api_token_tests {
4312    use std::sync::Arc;
4313
4314    use http::StatusCode;
4315
4316    use super::{
4317        ApiToken, ApiTokenStore, InMemoryApiTokenStore, RequireApiToken, hash_api_token,
4318        issue_api_token, revoke_api_token,
4319    };
4320
4321    struct FailingApiTokenStore;
4322
4323    impl ApiTokenStore for FailingApiTokenStore {
4324        fn issue<'a>(
4325            &'a self,
4326            _principal_id: &'a str,
4327        ) -> std::pin::Pin<
4328            Box<dyn std::future::Future<Output = crate::AutumnResult<String>> + Send + 'a>,
4329        > {
4330            Box::pin(async {
4331                Err(crate::AutumnError::service_unavailable_msg(
4332                    "api token store unavailable",
4333                ))
4334            })
4335        }
4336
4337        fn verify<'a>(
4338            &'a self,
4339            _raw_token: &'a str,
4340        ) -> std::pin::Pin<
4341            Box<dyn std::future::Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>,
4342        > {
4343            Box::pin(async {
4344                Err(crate::AutumnError::service_unavailable_msg(
4345                    "api token store unavailable",
4346                ))
4347            })
4348        }
4349
4350        fn revoke<'a>(
4351            &'a self,
4352            _raw_token: &'a str,
4353        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
4354        {
4355            Box::pin(async {
4356                Err(crate::AutumnError::service_unavailable_msg(
4357                    "api token store unavailable",
4358                ))
4359            })
4360        }
4361    }
4362
4363    // ── hash_api_token ───────────────────────────────────────────────────────
4364
4365    #[test]
4366    fn hash_api_token_is_deterministic() {
4367        let h1 = hash_api_token("abc123");
4368        let h2 = hash_api_token("abc123");
4369        assert_eq!(h1, h2);
4370    }
4371
4372    #[test]
4373    fn hash_api_token_produces_64_char_hex() {
4374        let hash = hash_api_token("any_raw_token");
4375        assert_eq!(hash.len(), 64, "SHA-256 hex must be 64 chars");
4376        assert!(
4377            hash.chars().all(|c| c.is_ascii_hexdigit()),
4378            "hash must be lowercase hex digits"
4379        );
4380    }
4381
4382    #[test]
4383    fn hash_api_token_differs_from_input() {
4384        let raw = "my_raw_token";
4385        assert_ne!(hash_api_token(raw), raw);
4386    }
4387
4388    #[test]
4389    fn hash_api_token_different_inputs_produce_different_hashes() {
4390        assert_ne!(hash_api_token("token_a"), hash_api_token("token_b"));
4391    }
4392
4393    // ── InMemoryApiTokenStore ────────────────────────────────────────────────
4394
4395    #[tokio::test]
4396    async fn in_memory_store_issue_returns_unique_tokens() {
4397        let store = InMemoryApiTokenStore::default();
4398        let t1 = store.issue("user:1").await.unwrap();
4399        let t2 = store.issue("user:1").await.unwrap();
4400        assert_ne!(t1, t2, "each issued token must be unique");
4401        assert!(t1.len() >= 32, "token must have sufficient entropy");
4402    }
4403
4404    #[tokio::test]
4405    async fn in_memory_store_verify_returns_principal_for_valid_token() {
4406        let store = InMemoryApiTokenStore::default();
4407        let raw = store.issue("user:42").await.unwrap();
4408        let principal = store.verify(&raw).await.unwrap();
4409        assert_eq!(principal, Some("user:42".to_owned()));
4410    }
4411
4412    #[tokio::test]
4413    async fn in_memory_store_verify_returns_none_for_unknown_token() {
4414        let store = InMemoryApiTokenStore::default();
4415        let result = store.verify("not_a_real_token").await.unwrap();
4416        assert_eq!(result, None);
4417    }
4418
4419    #[tokio::test]
4420    async fn in_memory_store_revoke_invalidates_token() {
4421        let store = InMemoryApiTokenStore::default();
4422        let raw = store.issue("user:7").await.unwrap();
4423        assert_eq!(
4424            store.verify(&raw).await.unwrap(),
4425            Some("user:7".to_owned()),
4426            "token must be valid before revoking"
4427        );
4428        store.revoke(&raw).await.unwrap();
4429        assert_eq!(store.verify(&raw).await.unwrap(), None);
4430    }
4431
4432    #[tokio::test]
4433    async fn in_memory_store_raw_token_not_stored_verbatim() {
4434        let store = InMemoryApiTokenStore::default();
4435        let raw = store.issue("user:1").await.unwrap();
4436        // Appending a character changes the hash → lookup must return None.
4437        let tampered = format!("{raw}x");
4438        assert_eq!(store.verify(&tampered).await.unwrap(), None);
4439    }
4440
4441    #[tokio::test]
4442    async fn issue_api_token_helper_issues_verifiable_token() {
4443        let store = InMemoryApiTokenStore::default();
4444        let raw = issue_api_token(&store, "user:5").await.unwrap();
4445        assert_eq!(store.verify(&raw).await.unwrap(), Some("user:5".to_owned()));
4446    }
4447
4448    #[tokio::test]
4449    async fn revoke_api_token_helper_revokes_token() {
4450        let store = InMemoryApiTokenStore::default();
4451        let raw = store.issue("user:6").await.unwrap();
4452        revoke_api_token(&store, &raw).await.unwrap();
4453        assert_eq!(store.verify(&raw).await.unwrap(), None);
4454    }
4455
4456    // ── Seeding a known token (issue #1970) ──────────────────────────────────
4457
4458    #[tokio::test]
4459    async fn with_token_seeds_token_resolvable_through_same_path() {
4460        // A seeded token must flow through the exact `verify` / `verify_scoped`
4461        // path a minted token uses — no second hashing or lookup scheme.
4462        let store = InMemoryApiTokenStore::default().with_token("known-dev-token", "user:dev");
4463        assert_eq!(
4464            store.verify("known-dev-token").await.unwrap(),
4465            Some("user:dev".to_owned()),
4466        );
4467        // The stored hash is exactly `hash_api_token(raw)` — a tampered raw
4468        // hashes differently and must miss.
4469        assert_eq!(store.verify("known-dev-tokenx").await.unwrap(), None);
4470        // And it resolves through the scoped path too (empty scopes).
4471        let verified = store
4472            .verify_scoped("known-dev-token")
4473            .await
4474            .unwrap()
4475            .unwrap();
4476        assert_eq!(verified.principal_id, "user:dev");
4477        assert!(verified.scopes.is_empty());
4478        // Revocation uses the same hash → the seeded token can be revoked.
4479        store.revoke("known-dev-token").await.unwrap();
4480        assert_eq!(store.verify("known-dev-token").await.unwrap(), None);
4481    }
4482
4483    #[tokio::test]
4484    async fn with_scoped_token_seeds_scopes_via_verify_scoped() {
4485        let granted = scopes(&["reports:read", "reports:write"]);
4486        let store = InMemoryApiTokenStore::default().with_scoped_token(
4487            "scoped-dev-token",
4488            "svc:reports",
4489            &granted,
4490        );
4491        let verified = store
4492            .verify_scoped("scoped-dev-token")
4493            .await
4494            .unwrap()
4495            .unwrap();
4496        assert_eq!(verified.principal_id, "svc:reports");
4497        assert_eq!(verified.scopes, granted);
4498    }
4499
4500    #[tokio::test]
4501    async fn blank_seed_token_stores_no_credential() {
4502        // A blank (empty or whitespace-only) seed on the infallible builders must
4503        // be a safe no-op — never minting a `hash("")` credential that a blank
4504        // `Authorization: Bearer ` header could satisfy. `with_token` and
4505        // `with_scoped_token` both route through the same `store_raw_token` guard.
4506        for blank in ["", "   ", "\t\n"] {
4507            let store = InMemoryApiTokenStore::default().with_token(blank, "user:oops");
4508            // Neither the blank raw value nor an empty bearer resolves anything.
4509            assert_eq!(store.verify(blank).await.unwrap(), None);
4510            assert_eq!(store.verify("").await.unwrap(), None);
4511            assert!(store.verify_scoped(blank).await.unwrap().is_none());
4512            assert!(store.verify_scoped("").await.unwrap().is_none());
4513
4514            // The scoped builder shares the guard.
4515            let scoped = InMemoryApiTokenStore::default().with_scoped_token(
4516                blank,
4517                "svc:oops",
4518                &scopes(&["reports:read"]),
4519            );
4520            assert_eq!(scoped.verify(blank).await.unwrap(), None);
4521            assert!(scoped.verify_scoped("").await.unwrap().is_none());
4522        }
4523
4524        // A normal non-blank seed alongside still works, proving the guard only
4525        // drops the blank one.
4526        let store = InMemoryApiTokenStore::default().with_token("real-token", "user:ok");
4527        assert_eq!(
4528            store.verify("real-token").await.unwrap(),
4529            Some("user:ok".to_owned()),
4530        );
4531    }
4532
4533    #[test]
4534    fn from_env_errors_when_variable_unset() {
4535        // The crate is `#![forbid(unsafe_code)]`, and `std::env::set_var` is
4536        // `unsafe` in edition 2024, so a test cannot mutate the process
4537        // environment to exercise the positive path here — the seeding core is
4538        // proven by the `with_token` / `with_scoped_token` tests above. This
4539        // proves `from_env` consults exactly the named variable and rejects an
4540        // unset one (a uniquely-named var is naturally absent, so no mutation is
4541        // needed).
4542        const VAR: &str = "AUTUMN_TEST_MCP_TOKEN_1970_UNSET";
4543        assert!(std::env::var(VAR).is_err(), "test var must be unset");
4544        assert!(InMemoryApiTokenStore::from_env(VAR, "user:mcp").is_err());
4545    }
4546
4547    // ── Scoped service tokens (issue #1158) ──────────────────────────────────
4548
4549    use super::{
4550        __check_secured_scopes, ApiTokenScopes, IssueTokenSpec, issue_scoped_api_token,
4551        list_api_tokens, rotate_api_token,
4552    };
4553    use crate::time::{FixedClock, TickingClock};
4554    use chrono::{Duration as ChronoDuration, TimeZone as _, Utc};
4555
4556    fn scopes(s: &[&str]) -> Vec<String> {
4557        s.iter().map(|x| (*x).to_owned()).collect()
4558    }
4559
4560    /// A legacy store implementing only the original three methods relies on
4561    /// the default `verify_scoped`, which must yield empty scopes — proving the
4562    /// scoped surface is purely additive.
4563    #[derive(Default)]
4564    struct LegacyOnlyStore(InMemoryApiTokenStore);
4565
4566    impl ApiTokenStore for LegacyOnlyStore {
4567        fn issue<'a>(
4568            &'a self,
4569            principal_id: &'a str,
4570        ) -> std::pin::Pin<
4571            Box<dyn std::future::Future<Output = crate::AutumnResult<String>> + Send + 'a>,
4572        > {
4573            self.0.issue(principal_id)
4574        }
4575        fn verify<'a>(
4576            &'a self,
4577            raw_token: &'a str,
4578        ) -> std::pin::Pin<
4579            Box<dyn std::future::Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>,
4580        > {
4581            self.0.verify(raw_token)
4582        }
4583        fn revoke<'a>(
4584            &'a self,
4585            raw_token: &'a str,
4586        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
4587        {
4588            self.0.revoke(raw_token)
4589        }
4590    }
4591
4592    #[tokio::test]
4593    async fn legacy_store_default_verify_scoped_yields_empty_scopes() {
4594        let store = LegacyOnlyStore::default();
4595        let raw = store.issue("user:1").await.unwrap();
4596        let verified = store.verify_scoped(&raw).await.unwrap().unwrap();
4597        assert_eq!(verified.principal_id, "user:1");
4598        assert!(verified.scopes.is_empty());
4599    }
4600
4601    #[tokio::test]
4602    async fn issue_scoped_round_trips_name_and_scopes() {
4603        let store = InMemoryApiTokenStore::default();
4604        let granted = scopes(&["posts:read", "posts:write"]);
4605        let raw = issue_scoped_api_token(
4606            &store,
4607            IssueTokenSpec {
4608                principal_id: "service:ci",
4609                name: "ci",
4610                scopes: &granted,
4611                expires_at: None,
4612            },
4613        )
4614        .await
4615        .unwrap();
4616
4617        let verified = store.verify_scoped(&raw).await.unwrap().unwrap();
4618        assert_eq!(verified.principal_id, "service:ci");
4619        assert_eq!(verified.scopes, granted);
4620
4621        let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4622        assert_eq!(listed.len(), 1);
4623        assert_eq!(listed[0].name, "ci");
4624        assert_eq!(listed[0].scopes, granted);
4625    }
4626
4627    #[tokio::test]
4628    async fn expired_token_verifies_as_none() {
4629        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
4630        let store = InMemoryApiTokenStore::default().with_clock(Arc::new(FixedClock::at(now)));
4631        let granted = scopes(&["posts:read"]);
4632        let raw = store
4633            .issue_scoped(IssueTokenSpec {
4634                principal_id: "service:ci",
4635                name: "ci",
4636                scopes: &granted,
4637                expires_at: Some(now - ChronoDuration::seconds(1)),
4638            })
4639            .await
4640            .unwrap();
4641
4642        assert_eq!(store.verify(&raw).await.unwrap(), None);
4643        assert!(store.verify_scoped(&raw).await.unwrap().is_none());
4644    }
4645
4646    #[tokio::test]
4647    async fn unexpired_token_verifies_then_records_last_used_at() {
4648        let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
4649        let clock = TickingClock::starting_at(start);
4650        let store = InMemoryApiTokenStore::default().with_clock(Arc::new(clock));
4651        let granted = scopes(&["posts:read"]);
4652        let raw = store
4653            .issue_scoped(IssueTokenSpec {
4654                principal_id: "service:ci",
4655                name: "ci",
4656                scopes: &granted,
4657                expires_at: Some(start + ChronoDuration::days(30)),
4658            })
4659            .await
4660            .unwrap();
4661
4662        // Before use, last_used_at is unset.
4663        assert!(
4664            list_api_tokens(&store, "service:ci").await.unwrap()[0]
4665                .last_used_at
4666                .is_none()
4667        );
4668
4669        assert!(store.verify_scoped(&raw).await.unwrap().is_some());
4670
4671        assert!(
4672            list_api_tokens(&store, "service:ci").await.unwrap()[0]
4673                .last_used_at
4674                .is_some()
4675        );
4676    }
4677
4678    #[tokio::test]
4679    async fn list_metadata_carries_no_secret_and_reflects_revocation() {
4680        let store = InMemoryApiTokenStore::default();
4681        let granted = scopes(&["posts:read"]);
4682        let raw = store
4683            .issue_scoped(IssueTokenSpec {
4684                principal_id: "service:ci",
4685                name: "ci",
4686                scopes: &granted,
4687                expires_at: None,
4688            })
4689            .await
4690            .unwrap();
4691
4692        let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4693        assert_eq!(listed.len(), 1);
4694        // Metadata must not expose anything replayable as a credential: the raw
4695        // token and its hash never appear in TokenMetadata's fields.
4696        assert!(listed[0].revoked_at.is_none());
4697
4698        store.revoke(&raw).await.unwrap();
4699        assert_eq!(store.verify(&raw).await.unwrap(), None);
4700        let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4701        assert!(listed[0].revoked_at.is_some());
4702    }
4703
4704    #[tokio::test]
4705    async fn rotate_revokes_old_and_preserves_scopes() {
4706        let store = InMemoryApiTokenStore::default();
4707        let granted = scopes(&["posts:read", "posts:write"]);
4708        let old = store
4709            .issue_scoped(IssueTokenSpec {
4710                principal_id: "service:ci",
4711                name: "ci",
4712                scopes: &granted,
4713                expires_at: None,
4714            })
4715            .await
4716            .unwrap();
4717
4718        let new = rotate_api_token(&store, &old).await.unwrap().unwrap();
4719        assert_ne!(new, old);
4720        // Old token no longer authenticates.
4721        assert!(store.verify_scoped(&old).await.unwrap().is_none());
4722        // New token carries the same scopes.
4723        let verified = store.verify_scoped(&new).await.unwrap().unwrap();
4724        assert_eq!(verified.scopes, granted);
4725        assert_eq!(verified.principal_id, "service:ci");
4726
4727        // Rotating an unknown token yields None.
4728        assert!(rotate_api_token(&store, "nope").await.unwrap().is_none());
4729    }
4730
4731    #[tokio::test]
4732    async fn check_secured_scopes_is_default_deny_and_all_must_match() {
4733        // Empty requirement is a no-op.
4734        assert!(__check_secured_scopes(None, &[]).await.is_ok());
4735        // No granted scopes but a requirement => denied (403).
4736        let err = __check_secured_scopes(None, &["posts:write"])
4737            .await
4738            .unwrap_err();
4739        assert_eq!(err.status(), StatusCode::FORBIDDEN);
4740        // Subset present => allowed.
4741        let granted = ApiTokenScopes(scopes(&["posts:read", "posts:write"]));
4742        assert!(
4743            __check_secured_scopes(Some(&granted), &["posts:write"])
4744                .await
4745                .is_ok()
4746        );
4747        // Missing one of several required => denied (all-must-match).
4748        assert!(
4749            __check_secured_scopes(Some(&granted), &["posts:write", "posts:delete"])
4750                .await
4751                .is_err()
4752        );
4753    }
4754
4755    // ── RequireApiToken middleware ───────────────────────────────────────────
4756
4757    #[tokio::test]
4758    async fn require_api_token_rejects_missing_authorization_header() {
4759        use axum::body::Body;
4760        use tower::ServiceExt;
4761
4762        let store = Arc::new(InMemoryApiTokenStore::default());
4763        let app = axum::Router::new()
4764            .route("/", axum::routing::get(|| async { "ok" }))
4765            .layer(RequireApiToken::new(store));
4766
4767        let response = app
4768            .oneshot(
4769                http::Request::builder()
4770                    .uri("/")
4771                    .body(Body::empty())
4772                    .unwrap(),
4773            )
4774            .await
4775            .unwrap();
4776
4777        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4778    }
4779
4780    #[tokio::test]
4781    async fn require_api_token_rejects_non_bearer_scheme() {
4782        use axum::body::Body;
4783        use tower::ServiceExt;
4784
4785        let store = Arc::new(InMemoryApiTokenStore::default());
4786        let app = axum::Router::new()
4787            .route("/", axum::routing::get(|| async { "ok" }))
4788            .layer(RequireApiToken::new(store));
4789
4790        let response = app
4791            .oneshot(
4792                http::Request::builder()
4793                    .uri("/")
4794                    .header(http::header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
4795                    .body(Body::empty())
4796                    .unwrap(),
4797            )
4798            .await
4799            .unwrap();
4800
4801        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4802    }
4803
4804    #[tokio::test]
4805    async fn require_api_token_rejects_unknown_bearer_token() {
4806        use axum::body::Body;
4807        use tower::ServiceExt;
4808
4809        let store = Arc::new(InMemoryApiTokenStore::default());
4810        let app = axum::Router::new()
4811            .route("/", axum::routing::get(|| async { "ok" }))
4812            .layer(RequireApiToken::new(store));
4813
4814        let response = app
4815            .oneshot(
4816                http::Request::builder()
4817                    .uri("/")
4818                    .header(http::header::AUTHORIZATION, "Bearer unknown_token_xyz")
4819                    .body(Body::empty())
4820                    .unwrap(),
4821            )
4822            .await
4823            .unwrap();
4824
4825        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4826    }
4827
4828    #[tokio::test]
4829    async fn require_api_token_propagates_store_verify_errors() {
4830        use axum::body::Body;
4831        use tower::ServiceExt;
4832
4833        let store = Arc::new(FailingApiTokenStore);
4834        let app = axum::Router::new()
4835            .route("/", axum::routing::get(|| async { "ok" }))
4836            .layer(RequireApiToken::new(store));
4837
4838        let response = app
4839            .oneshot(
4840                http::Request::builder()
4841                    .uri("/")
4842                    .header(http::header::AUTHORIZATION, "Bearer valid_client_token")
4843                    .body(Body::empty())
4844                    .unwrap(),
4845            )
4846            .await
4847            .unwrap();
4848
4849        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4850        assert_eq!(
4851            response
4852                .headers()
4853                .get(http::header::CONTENT_TYPE)
4854                .map(|value| value.to_str().unwrap_or_default()),
4855            Some("application/problem+json")
4856        );
4857
4858        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4859            .await
4860            .unwrap();
4861        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4862        assert_eq!(json["status"], 503);
4863        assert_eq!(json["code"], "autumn.service_unavailable");
4864        assert_eq!(json["detail"], "api token store unavailable");
4865    }
4866
4867    #[tokio::test]
4868    async fn require_api_token_allows_valid_bearer_token() {
4869        use axum::body::Body;
4870        use tower::ServiceExt;
4871
4872        let store = Arc::new(InMemoryApiTokenStore::default());
4873        let raw = store.issue("user:1").await.unwrap();
4874        let app = axum::Router::new()
4875            .route("/", axum::routing::get(|| async { "ok" }))
4876            .layer(RequireApiToken::new(Arc::clone(&store)));
4877
4878        let response = app
4879            .oneshot(
4880                http::Request::builder()
4881                    .uri("/")
4882                    .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
4883                    .body(Body::empty())
4884                    .unwrap(),
4885            )
4886            .await
4887            .unwrap();
4888
4889        assert_eq!(response.status(), StatusCode::OK);
4890    }
4891
4892    #[tokio::test]
4893    async fn require_api_token_accepts_case_insensitive_bearer_scheme() {
4894        use axum::body::Body;
4895        use tower::ServiceExt;
4896
4897        let store = Arc::new(InMemoryApiTokenStore::default());
4898        let raw = store.issue("user:1").await.unwrap();
4899
4900        for scheme in ["bearer", "bEaReR"] {
4901            let app = axum::Router::new()
4902                .route("/", axum::routing::get(|| async { "ok" }))
4903                .layer(RequireApiToken::new(Arc::clone(&store)));
4904
4905            let response = app
4906                .oneshot(
4907                    http::Request::builder()
4908                        .uri("/")
4909                        .header(http::header::AUTHORIZATION, format!("{scheme} {raw}"))
4910                        .body(Body::empty())
4911                        .unwrap(),
4912                )
4913                .await
4914                .unwrap();
4915
4916            assert_eq!(response.status(), StatusCode::OK, "scheme {scheme}");
4917        }
4918    }
4919
4920    #[tokio::test]
4921    async fn require_api_token_rejects_revoked_token() {
4922        use axum::body::Body;
4923        use tower::ServiceExt;
4924
4925        let store = Arc::new(InMemoryApiTokenStore::default());
4926        let raw = store.issue("user:1").await.unwrap();
4927        store.revoke(&raw).await.unwrap();
4928        let app = axum::Router::new()
4929            .route("/", axum::routing::get(|| async { "ok" }))
4930            .layer(RequireApiToken::new(Arc::clone(&store)));
4931
4932        let response = app
4933            .oneshot(
4934                http::Request::builder()
4935                    .uri("/")
4936                    .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
4937                    .body(Body::empty())
4938                    .unwrap(),
4939            )
4940            .await
4941            .unwrap();
4942
4943        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4944    }
4945
4946    #[tokio::test]
4947    async fn require_api_token_401_response_has_problem_details() {
4948        use axum::body::Body;
4949        use tower::ServiceExt;
4950
4951        let store = Arc::new(InMemoryApiTokenStore::default());
4952        let app = axum::Router::new()
4953            .route("/", axum::routing::get(|| async { "ok" }))
4954            .layer(RequireApiToken::new(store));
4955
4956        let response = app
4957            .oneshot(
4958                http::Request::builder()
4959                    .uri("/")
4960                    .body(Body::empty())
4961                    .unwrap(),
4962            )
4963            .await
4964            .unwrap();
4965
4966        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4967        assert_eq!(
4968            response
4969                .headers()
4970                .get(http::header::CONTENT_TYPE)
4971                .map(|v| v.to_str().unwrap_or_default()),
4972            Some("application/problem+json")
4973        );
4974        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4975            .await
4976            .unwrap();
4977        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4978        assert_eq!(json["status"], 401);
4979        assert_eq!(json["code"], "autumn.unauthorized");
4980        assert!(json["detail"].as_str().is_some());
4981    }
4982
4983    #[tokio::test]
4984    async fn require_api_token_401_problem_details_include_request_context() {
4985        use crate::middleware::RequestIdLayer;
4986        use axum::body::Body;
4987        use tower::ServiceExt;
4988
4989        let store = Arc::new(InMemoryApiTokenStore::default());
4990        let app = axum::Router::new()
4991            .route("/api/private", axum::routing::get(|| async { "ok" }))
4992            .layer(RequireApiToken::new(store))
4993            .layer(RequestIdLayer);
4994
4995        let response = app
4996            .oneshot(
4997                http::Request::builder()
4998                    .uri("/api/private")
4999                    .body(Body::empty())
5000                    .unwrap(),
5001            )
5002            .await
5003            .unwrap();
5004
5005        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
5006        let request_id = response
5007            .headers()
5008            .get("x-request-id")
5009            .and_then(|value| value.to_str().ok())
5010            .expect("request id header should be present")
5011            .to_owned();
5012        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5013            .await
5014            .unwrap();
5015        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5016        assert_eq!(json["request_id"], request_id);
5017        assert_eq!(json["instance"], "/api/private");
5018    }
5019
5020    // ── ApiToken extractor ───────────────────────────────────────────────────
5021
5022    #[tokio::test]
5023    async fn api_token_extractor_yields_principal_id_to_handler() {
5024        use axum::body::Body;
5025        use tower::ServiceExt;
5026
5027        async fn handler(ApiToken(principal): ApiToken) -> String {
5028            principal
5029        }
5030
5031        let store = Arc::new(InMemoryApiTokenStore::default());
5032        let raw = store.issue("user:99").await.unwrap();
5033        let app = axum::Router::new()
5034            .route("/", axum::routing::get(handler))
5035            .layer(RequireApiToken::new(Arc::clone(&store)));
5036
5037        let response = app
5038            .oneshot(
5039                http::Request::builder()
5040                    .uri("/")
5041                    .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
5042                    .body(Body::empty())
5043                    .unwrap(),
5044            )
5045            .await
5046            .unwrap();
5047
5048        assert_eq!(response.status(), StatusCode::OK);
5049        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5050            .await
5051            .unwrap();
5052        assert_eq!(std::str::from_utf8(&body).unwrap(), "user:99");
5053    }
5054
5055    #[tokio::test]
5056    async fn api_token_extractor_rejects_when_no_principal_in_extensions() {
5057        use axum::body::Body;
5058        use tower::ServiceExt;
5059
5060        async fn handler(ApiToken(principal): ApiToken) -> String {
5061            principal
5062        }
5063
5064        let app = axum::Router::new().route("/", axum::routing::get(handler));
5065
5066        let response = app
5067            .oneshot(
5068                http::Request::builder()
5069                    .uri("/")
5070                    .body(Body::empty())
5071                    .unwrap(),
5072            )
5073            .await
5074            .unwrap();
5075
5076        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
5077    }
5078
5079    // ── Composition with session auth ────────────────────────────────────────
5080
5081    #[tokio::test]
5082    async fn api_token_and_session_auth_compose_without_conflict() {
5083        use axum::body::Body;
5084        use tower::ServiceExt;
5085
5086        use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
5087
5088        async fn api_handler(ApiToken(principal): ApiToken) -> String {
5089            principal
5090        }
5091
5092        let store = Arc::new(InMemoryApiTokenStore::default());
5093        let raw = store.issue("api_user").await.unwrap();
5094
5095        let session_store = MemoryStore::new();
5096        session_store
5097            .save(
5098                "sess1",
5099                std::collections::HashMap::from([("user_id".into(), "session_user".into())]),
5100            )
5101            .await
5102            .unwrap();
5103
5104        let app = axum::Router::new()
5105            .route(
5106                "/api",
5107                axum::routing::get(api_handler).layer(RequireApiToken::new(Arc::clone(&store))),
5108            )
5109            .layer(SessionLayer::new(session_store, SessionConfig::default()));
5110
5111        let response = app
5112            .oneshot(
5113                http::Request::builder()
5114                    .uri("/api")
5115                    .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
5116                    .body(Body::empty())
5117                    .unwrap(),
5118            )
5119            .await
5120            .unwrap();
5121
5122        assert_eq!(response.status(), StatusCode::OK);
5123        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5124            .await
5125            .unwrap();
5126        assert_eq!(std::str::from_utf8(&body).unwrap(), "api_user");
5127    }
5128
5129    // ── poll_ready propagation ───────────────────────────────────────────────
5130
5131    #[tokio::test]
5132    async fn require_api_token_poll_ready_propagates_to_inner() {
5133        use std::task::{Context, Poll};
5134        use tower::{Layer, Service};
5135
5136        #[derive(Clone)]
5137        struct MockService {
5138            ready: bool,
5139        }
5140
5141        impl tower::Service<axum::extract::Request> for MockService {
5142            type Response = axum::response::Response;
5143            type Error = std::convert::Infallible;
5144            type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
5145
5146            fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
5147                if self.ready {
5148                    Poll::Ready(Ok(()))
5149                } else {
5150                    Poll::Pending
5151                }
5152            }
5153
5154            fn call(&mut self, _req: axum::extract::Request) -> Self::Future {
5155                std::future::ready(Ok(axum::response::Response::new(axum::body::Body::empty())))
5156            }
5157        }
5158
5159        let waker = futures::task::noop_waker();
5160        let mut cx = Context::from_waker(&waker);
5161
5162        let store = Arc::new(InMemoryApiTokenStore::default());
5163        let layer = RequireApiToken::new(store);
5164        let mut svc = layer.layer(MockService { ready: false });
5165        assert!(svc.poll_ready(&mut cx).is_pending());
5166
5167        let store2 = Arc::new(InMemoryApiTokenStore::default());
5168        let layer2 = RequireApiToken::new(store2);
5169        let mut svc2 = layer2.layer(MockService { ready: true });
5170        assert!(svc2.poll_ready(&mut cx).is_ready());
5171    }
5172
5173    #[tokio::test]
5174    async fn require_api_token_rate_limits_by_principal() {
5175        // Verifies end-to-end: RequireApiToken (outer) sets RateLimitPrincipal
5176        // with the VERIFIED principal ID so a route-scoped RateLimitLayer (inner)
5177        // keys on the principal — two different tokens for the same principal share
5178        // one bucket.  Layer composition: RequireApiToken → RateLimitLayer → handler
5179        use axum::body::Body;
5180        use tower::ServiceExt;
5181
5182        use crate::security::{KeyStrategy, RateLimitConfig, RateLimitLayer};
5183
5184        let rl_config = RateLimitConfig {
5185            enabled: true,
5186            requests_per_second: 0.1,
5187            burst: 1,
5188            key_strategy: KeyStrategy::AuthenticatedPrincipal,
5189            ..Default::default()
5190        };
5191
5192        let store = Arc::new(InMemoryApiTokenStore::default());
5193        let token_a1 = issue_api_token(&*store, "principal-1").await.unwrap();
5194        let token_a2 = issue_api_token(&*store, "principal-1").await.unwrap(); // second token, same principal
5195        let token_b = issue_api_token(&*store, "principal-2").await.unwrap();
5196
5197        let app = axum::Router::new()
5198            .route("/", axum::routing::get(|| async { "ok" }))
5199            .layer(RateLimitLayer::from_config(&rl_config)) // inner — reads principal
5200            .layer(RequireApiToken::new(Arc::clone(&store))); // outer — sets RateLimitPrincipal
5201
5202        // principal-1, first token: allowed.
5203        let r = app
5204            .clone()
5205            .oneshot(
5206                http::Request::builder()
5207                    .uri("/")
5208                    .header("authorization", format!("Bearer {token_a1}"))
5209                    .body(Body::empty())
5210                    .unwrap(),
5211            )
5212            .await
5213            .unwrap();
5214        assert_eq!(r.status(), StatusCode::OK);
5215
5216        // principal-1, different token: shares the same bucket → 429.
5217        let r = app
5218            .clone()
5219            .oneshot(
5220                http::Request::builder()
5221                    .uri("/")
5222                    .header("authorization", format!("Bearer {token_a2}"))
5223                    .body(Body::empty())
5224                    .unwrap(),
5225            )
5226            .await
5227            .unwrap();
5228        assert_eq!(
5229            r.status(),
5230            StatusCode::TOO_MANY_REQUESTS,
5231            "second token for the same principal must share the rate-limit bucket"
5232        );
5233
5234        // principal-2: separate bucket → allowed.
5235        let r = app
5236            .clone()
5237            .oneshot(
5238                http::Request::builder()
5239                    .uri("/")
5240                    .header("authorization", format!("Bearer {token_b}"))
5241                    .body(Body::empty())
5242                    .unwrap(),
5243            )
5244            .await
5245            .unwrap();
5246        assert_eq!(r.status(), StatusCode::OK);
5247    }
5248
5249    #[tokio::test]
5250    async fn require_api_token_sets_rate_limit_principal() {
5251        use axum::body::Body;
5252        use tower::ServiceExt;
5253
5254        use crate::security::RateLimitPrincipal;
5255
5256        async fn handler(
5257            axum::Extension(principal): axum::Extension<RateLimitPrincipal>,
5258        ) -> String {
5259            principal.0
5260        }
5261
5262        let store = Arc::new(InMemoryApiTokenStore::default());
5263        let raw = issue_api_token(&*store, "agent:bot").await.unwrap();
5264
5265        let app = axum::Router::new()
5266            .route("/", axum::routing::get(handler))
5267            .layer(RequireApiToken::new(Arc::clone(&store)));
5268
5269        let response = app
5270            .oneshot(
5271                http::Request::builder()
5272                    .uri("/")
5273                    .header("authorization", format!("Bearer {raw}"))
5274                    .body(Body::empty())
5275                    .unwrap(),
5276            )
5277            .await
5278            .unwrap();
5279
5280        assert_eq!(response.status(), StatusCode::OK);
5281        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5282            .await
5283            .unwrap();
5284        assert_eq!(std::str::from_utf8(&body).unwrap(), "agent:bot");
5285    }
5286}
5287
5288// ── OAuth2 unit tests (separate module for clean imports) ─────────────────────
5289
5290#[cfg(feature = "oauth2")]
5291#[cfg(test)]
5292mod oauth2_unit_tests {
5293    use std::collections::HashMap;
5294
5295    use super::{
5296        AuthConfig, OAuth2ProviderConfig, OAuthLinkingPolicy, oauth2_authorize_url, provider_preset,
5297    };
5298
5299    #[allow(dead_code)]
5300    fn make_provider(authorize_url: &str) -> OAuth2ProviderConfig {
5301        OAuth2ProviderConfig {
5302            client_id: "cid".into(),
5303            client_secret: "secret".into(),
5304            authorize_url: authorize_url.into(),
5305            token_url: "https://idp.example/token".into(),
5306            userinfo_url: None,
5307            redirect_uri: "http://localhost:3000/callback".into(),
5308            scope: "openid profile".into(),
5309            issuer: None,
5310            jwks_url: None,
5311            discovery_url: None,
5312        }
5313    }
5314
5315    #[test]
5316    fn provider_preset_google_returns_oidc_config() {
5317        let preset = provider_preset("google").expect("google preset must exist");
5318        assert!(
5319            !preset.authorize_url.is_empty(),
5320            "google authorize_url must not be empty"
5321        );
5322        assert!(
5323            !preset.token_url.is_empty(),
5324            "google token_url must not be empty"
5325        );
5326        assert!(
5327            preset.discovery_url.is_some(),
5328            "google must have discovery_url for OIDC"
5329        );
5330        assert!(
5331            preset.scope.contains("openid"),
5332            "google preset scope must include openid: {}",
5333            preset.scope
5334        );
5335        assert!(
5336            preset.scope.contains("email"),
5337            "google preset scope must include email: {}",
5338            preset.scope
5339        );
5340        assert_eq!(
5341            preset.client_id, "",
5342            "client_id must be empty in preset (user fills in)"
5343        );
5344        assert_eq!(
5345            preset.client_secret, "",
5346            "client_secret must be empty in preset"
5347        );
5348        assert_eq!(
5349            preset.redirect_uri, "",
5350            "redirect_uri must be empty in preset"
5351        );
5352    }
5353
5354    #[cfg(feature = "oauth2")]
5355    #[test]
5356    fn provider_preset_github_returns_pure_oauth2_config() {
5357        let preset = provider_preset("github").expect("github preset must exist");
5358        assert!(
5359            !preset.authorize_url.is_empty(),
5360            "github authorize_url must not be empty"
5361        );
5362        assert!(
5363            !preset.token_url.is_empty(),
5364            "github token_url must not be empty"
5365        );
5366        assert!(
5367            preset.userinfo_url.is_some(),
5368            "github must have userinfo_url (it is not OIDC)"
5369        );
5370        assert!(
5371            preset.discovery_url.is_none(),
5372            "github must NOT have discovery_url (pure OAuth2)"
5373        );
5374    }
5375
5376    #[cfg(feature = "oauth2")]
5377    #[test]
5378    fn provider_preset_microsoft_returns_oidc_config() {
5379        let preset = provider_preset("microsoft").expect("microsoft preset must exist");
5380        assert!(
5381            !preset.authorize_url.is_empty(),
5382            "microsoft authorize_url must not be empty"
5383        );
5384        assert!(
5385            preset.discovery_url.is_some(),
5386            "microsoft must have discovery_url for OIDC"
5387        );
5388        assert!(
5389            preset.scope.contains("openid"),
5390            "microsoft preset scope must include openid: {}",
5391            preset.scope
5392        );
5393    }
5394
5395    #[cfg(feature = "oauth2")]
5396    #[test]
5397    fn provider_preset_unknown_returns_none() {
5398        assert!(
5399            provider_preset("nonexistent_provider_xyz").is_none(),
5400            "unknown provider must return None"
5401        );
5402    }
5403
5404    #[cfg(feature = "oauth2")]
5405    #[tokio::test]
5406    async fn oauth2_authorize_url_includes_pkce_code_challenge() {
5407        let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
5408        let provider = OAuth2ProviderConfig {
5409            client_id: "cid".into(),
5410            client_secret: "secret".into(),
5411            authorize_url: "https://idp.example/authorize".into(),
5412            token_url: "https://idp.example/token".into(),
5413            userinfo_url: None,
5414            redirect_uri: "http://localhost:3000/callback".into(),
5415            scope: "openid profile".into(),
5416            issuer: None,
5417            jwks_url: None,
5418            discovery_url: None,
5419        };
5420        let url = oauth2_authorize_url(&session, "testprovider", &provider)
5421            .await
5422            .unwrap();
5423        assert!(
5424            url.contains("code_challenge="),
5425            "PKCE code_challenge must be present in URL: {url}"
5426        );
5427        assert!(
5428            url.contains("code_challenge_method=S256"),
5429            "PKCE method must be S256: {url}"
5430        );
5431        assert!(
5432            session
5433                .get("oauth2:testprovider:code_verifier")
5434                .await
5435                .is_some(),
5436            "code_verifier must be stored in session for later exchange"
5437        );
5438    }
5439
5440    #[cfg(feature = "oauth2")]
5441    #[test]
5442    fn oauth2_provider_config_has_discovery_url_field() {
5443        let provider = OAuth2ProviderConfig {
5444            client_id: "cid".into(),
5445            client_secret: "secret".into(),
5446            authorize_url: "https://idp.example/authorize".into(),
5447            token_url: "https://idp.example/token".into(),
5448            userinfo_url: None,
5449            redirect_uri: "http://localhost:3000/callback".into(),
5450            scope: String::new(),
5451            issuer: None,
5452            jwks_url: None,
5453            discovery_url: Some("https://idp.example".into()),
5454        };
5455        assert_eq!(
5456            provider.discovery_url.as_deref(),
5457            Some("https://idp.example"),
5458            "discovery_url must be accessible as a field"
5459        );
5460    }
5461
5462    #[cfg(feature = "oauth2")]
5463    #[test]
5464    fn auth_config_has_oauth_linking_policy() {
5465        let config = AuthConfig::default();
5466        // Default policy must be CreateAccount so unknown provider identities
5467        // automatically create a local user record.
5468        assert!(
5469            matches!(
5470                config.oauth_linking_policy,
5471                OAuthLinkingPolicy::CreateAccount
5472            ),
5473            "default linking policy must be CreateAccount"
5474        );
5475    }
5476}