Skip to main content

soma_auth/
middleware.rs

1//! Dual-mode bearer/JWT/cookie auth middleware shipped as a [`tower::Layer`].
2//!
3//! Consumers integrate with `.layer(AuthLayer::new(...))` rather than wrapping
4//! a free `authenticate_request` function in a closure-of-7-args. The
5//! middleware writes an [`AuthContext`] into request extensions on success,
6//! returns an [`AuthError`]-shaped 401 response on failure, and (for cookie
7//! mode + browser GETs) optionally redirects to a configured login path so the
8//! configured OAuth/OIDC flow can establish a session.
9//!
10//! Precedence (matches the legacy lab middleware):
11//!
12//! 1. `Authorization: Bearer <token>` matches the static bearer (constant-time
13//!    compare) -> grants `static_token_scopes`.
14//! 2. `Authorization: Bearer <token>` validates as a JWT issued by the local
15//!    auth state (audience + issuer enforced inside
16//!    [`crate::jwt::SigningKeys::validate_access_token_with_issuer`]) ->
17//!    grants the JWT-claim scopes.
18//! 3. (Optional, when [`AuthLayer`] was constructed with
19//!    `allow_session_cookie = true`.) Browser session cookie matches a row in
20//!    the auth store, with CSRF enforced for non-GET/HEAD/OPTIONS.
21//! 4. Otherwise, browser GET requests with `Accept: text/html` are redirected
22//!    to the configured login path; everything else returns 401 with
23//!    `WWW-Authenticate: Bearer resource_metadata=...`.
24
25use std::convert::Infallible;
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29use std::task::{Context, Poll};
30use std::time::{Duration, Instant};
31
32use axum::body::Body;
33use axum::http::{HeaderValue, Method, Request, header};
34use axum::response::{IntoResponse, Redirect, Response};
35use dashmap::DashMap;
36use subtle::ConstantTimeEq;
37use tower::{Layer, Service};
38
39use crate::auth_context::{AuthContext, www_authenticate_value};
40use crate::error::AuthError;
41use crate::metadata::canonical_resource_url;
42use crate::session;
43use crate::state::AuthState;
44
45/// Closure-erased actor-key derivation hook.
46///
47/// Consumers that have a notion of an opaque actor identifier (lab uses an
48/// HMAC over the JWT subject for non-PII observability) build one and pass
49/// it through [`AuthLayer::with_actor_key_deriver`]. Consumers without this
50/// concept (e.g. cortex) leave it unset.
51///
52/// The closure receives the JWT `sub` (or `"static-bearer"` /
53/// browser-session subject) and returns a per-request [`Arc<str>`] key.
54pub type ActorKeyDeriver = dyn Fn(&str) -> Option<Arc<str>> + Send + Sync;
55
56/// Warn-log window for rejected bearer tokens: at most one warn per token
57/// fingerprint per window.
58const FAILED_AUTH_LOG_WINDOW: Duration = Duration::from_secs(60);
59
60/// Cap on distinct fingerprints tracked. Under a many-token flood the log
61/// suppresses (rather than grows or floods) once the cap is hit.
62const FAILED_AUTH_LOG_MAX_ENTRIES: usize = 1024;
63
64/// Rate-limited observability for rejected bearer tokens (pattern ported
65/// from cortex's ingest auth): operators get a `warn` carrying a short
66/// SHA-256 fingerprint of the presented token — never the token itself —
67/// at most once per fingerprint per [`FAILED_AUTH_LOG_WINDOW`], making
68/// brute-force attempts visible without log flooding.
69struct FailedAuthLog {
70    entries: DashMap<String, Instant>,
71}
72
73impl FailedAuthLog {
74    fn new() -> Self {
75        Self {
76            entries: DashMap::new(),
77        }
78    }
79
80    /// Returns `true` when this rejection should be warn-logged.
81    fn should_log(&self, fingerprint: &str) -> bool {
82        let now = Instant::now();
83        if let Some(last) = self.entries.get(fingerprint)
84            && now.duration_since(*last) < FAILED_AUTH_LOG_WINDOW
85        {
86            return false;
87        }
88        if self.entries.len() >= FAILED_AUTH_LOG_MAX_ENTRIES
89            && !self.entries.contains_key(fingerprint)
90        {
91            self.entries
92                .retain(|_, seen| now.duration_since(*seen) < FAILED_AUTH_LOG_WINDOW);
93            if self.entries.len() >= FAILED_AUTH_LOG_MAX_ENTRIES {
94                return false;
95            }
96        }
97        self.entries.insert(fingerprint.to_string(), now);
98        true
99    }
100}
101
102/// Tower layer that authenticates inbound requests and writes
103/// [`AuthContext`] into request extensions.
104///
105/// Construct via [`AuthLayer::new`] and customize with the chained
106/// `with_*` helpers.
107#[derive(Clone)]
108pub struct AuthLayer {
109    inner: Arc<AuthLayerInner>,
110}
111
112#[derive(Clone)]
113struct AuthLayerInner {
114    static_token: Option<Arc<str>>,
115    auth_state: Option<Arc<AuthState>>,
116    actor_key_deriver: Option<Arc<ActorKeyDeriver>>,
117    resource_url: Option<Arc<str>>,
118    allow_session_cookie: bool,
119    /// Scopes minted into the [`AuthContext`] when the static bearer or
120    /// session-cookie path matches. For the static path this is the legacy
121    /// `static_token_scopes` config; for the cookie path lab keeps the same
122    /// list (browser-session subjects are admin-equivalent today).
123    static_token_scopes: Vec<String>,
124    /// Browser login path used for the GET+text/html unauthenticated
125    /// redirect (when `allow_session_cookie` is `true`). Defaults to
126    /// `/auth/login` per [`crate::config::DEFAULT_LOGIN_PATH`].
127    login_path: String,
128    /// Browser session cookie name. Read from
129    /// [`crate::config::AuthConfig::session_cookie_name`] when an
130    /// `auth_state` is supplied; otherwise this is unused.
131    session_cookie_name: String,
132    /// Shared rejected-bearer observability state. Deliberately behind its
133    /// own `Arc` so `Arc::make_mut` builder clones keep sharing one log.
134    failed_auth_log: Arc<FailedAuthLog>,
135}
136
137impl AuthLayer {
138    /// Build a bearer-only layer with neither a static token nor an auth
139    /// state. Such a layer always rejects requests with 401 — useful only
140    /// as a placeholder; real consumers immediately chain at least one of
141    /// [`Self::with_static_token`] / [`Self::with_auth_state`].
142    #[must_use]
143    pub fn new() -> Self {
144        Self {
145            inner: Arc::new(AuthLayerInner {
146                static_token: None,
147                auth_state: None,
148                actor_key_deriver: None,
149                resource_url: None,
150                allow_session_cookie: false,
151                static_token_scopes: Vec::new(),
152                login_path: crate::config::DEFAULT_LOGIN_PATH.to_string(),
153                session_cookie_name: crate::config::DEFAULT_SESSION_COOKIE_NAME.to_string(),
154                failed_auth_log: Arc::new(FailedAuthLog::new()),
155            }),
156        }
157    }
158
159    /// Convenience constructor that pulls
160    /// `static_token_scopes`, `login_path`, and `session_cookie_name`
161    /// directly from the supplied [`AuthState`]'s config — typically the
162    /// only call sites consumers need.
163    #[must_use]
164    pub fn from_state(auth_state: Arc<AuthState>) -> Self {
165        let cfg = &auth_state.config;
166        let static_token_scopes = cfg.static_token_scopes.clone();
167        let login_path = cfg.login_path.clone();
168        let session_cookie_name = cfg.session_cookie_name.clone();
169        Self {
170            inner: Arc::new(AuthLayerInner {
171                static_token: None,
172                auth_state: Some(auth_state),
173                actor_key_deriver: None,
174                resource_url: None,
175                allow_session_cookie: false,
176                static_token_scopes,
177                login_path,
178                session_cookie_name,
179                failed_auth_log: Arc::new(FailedAuthLog::new()),
180            }),
181        }
182    }
183
184    fn with(mut self, mutate: impl FnOnce(&mut AuthLayerInner)) -> Self {
185        let inner = Arc::make_mut(&mut self.inner);
186        mutate(inner);
187        self
188    }
189
190    #[must_use]
191    pub fn with_static_token(self, token: Option<Arc<str>>) -> Self {
192        self.with(|inner| inner.static_token = token)
193    }
194
195    #[must_use]
196    pub fn with_auth_state(self, state: Option<Arc<AuthState>>) -> Self {
197        self.with(|inner| {
198            if let Some(state) = state.as_ref() {
199                let cfg = &state.config;
200                inner.static_token_scopes = cfg.static_token_scopes.clone();
201                inner.login_path = cfg.login_path.clone();
202                inner.session_cookie_name = cfg.session_cookie_name.clone();
203            }
204            inner.auth_state = state;
205        })
206    }
207
208    #[must_use]
209    pub fn with_actor_key_deriver(self, deriver: Option<Arc<ActorKeyDeriver>>) -> Self {
210        self.with(|inner| inner.actor_key_deriver = deriver)
211    }
212
213    #[must_use]
214    pub fn with_resource_url(self, resource_url: Option<Arc<str>>) -> Self {
215        self.with(|inner| inner.resource_url = resource_url)
216    }
217
218    #[must_use]
219    pub fn with_allow_session_cookie(self, allow: bool) -> Self {
220        self.with(|inner| inner.allow_session_cookie = allow)
221    }
222
223    /// Override the static-token scope list (defaults to the value pulled
224    /// from `AuthConfig::static_token_scopes` via [`Self::from_state`] /
225    /// [`Self::with_auth_state`]).
226    #[must_use]
227    pub fn with_static_token_scopes(self, scopes: Vec<String>) -> Self {
228        self.with(|inner| inner.static_token_scopes = scopes)
229    }
230
231    /// Override the browser login path used for the GET+text/html
232    /// unauthenticated redirect.
233    #[must_use]
234    pub fn with_login_path(self, path: impl Into<String>) -> Self {
235        self.with(|inner| inner.login_path = path.into())
236    }
237
238    /// Override the session cookie name read from inbound requests.
239    #[must_use]
240    pub fn with_session_cookie_name(self, name: impl Into<String>) -> Self {
241        self.with(|inner| inner.session_cookie_name = name.into())
242    }
243}
244
245impl Default for AuthLayer {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl<S> Layer<S> for AuthLayer {
252    type Service = AuthService<S>;
253
254    fn layer(&self, inner: S) -> Self::Service {
255        AuthService {
256            inner,
257            layer: self.inner.clone(),
258        }
259    }
260}
261
262/// Service half of [`AuthLayer`]. Forwards to `inner` after a successful
263/// authentication; otherwise short-circuits with a 401 / redirect response.
264#[derive(Clone)]
265pub struct AuthService<S> {
266    inner: S,
267    layer: Arc<AuthLayerInner>,
268}
269
270impl<S> Service<Request<Body>> for AuthService<S>
271where
272    S: Service<Request<Body>, Response = Response, Error = Infallible> + Clone + Send + 'static,
273    S::Future: Send + 'static,
274{
275    type Response = Response;
276    type Error = Infallible;
277    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
278
279    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
280        self.inner.poll_ready(cx)
281    }
282
283    fn call(&mut self, request: Request<Body>) -> Self::Future {
284        // Per tower::Service contract, `call` may take a stale `self.inner`
285        // because Service callers clone the service before calling. We follow
286        // the standard tower middleware idiom: clone, then swap so the
287        // freshly-readied service is the one we call.
288        let clone = self.inner.clone();
289        let inner = std::mem::replace(&mut self.inner, clone);
290        let layer = self.layer.clone();
291        Box::pin(authenticate_and_forward(layer, inner, request))
292    }
293}
294
295async fn authenticate_and_forward<S>(
296    layer: Arc<AuthLayerInner>,
297    mut inner: S,
298    request: Request<Body>,
299) -> Result<Response, Infallible>
300where
301    S: Service<Request<Body>, Response = Response, Error = Infallible> + Send,
302    S::Future: Send,
303{
304    match authenticate(&layer, request).await {
305        Ok(request) => inner.call(request).await,
306        Err(response) => Ok(response),
307    }
308}
309
310/// Core authentication routine. Returns the (possibly mutated) request on
311/// success so the wrapping Service can forward it; returns a finished
312/// [`Response`] on failure (401, redirect, etc.).
313async fn authenticate(
314    layer: &AuthLayerInner,
315    mut request: Request<Body>,
316) -> Result<Request<Body>, Response> {
317    let auth_header = request
318        .headers()
319        .get(header::AUTHORIZATION)
320        .and_then(|v| v.to_str().ok())
321        .and_then(parse_bearer_token);
322
323    // Scope hint surfaced in `WWW-Authenticate: Bearer ... scope="..."` per
324    // RFC 6750 Section 3 / MCP spec guidance. Only available when the layer
325    // carries an `AuthState` (i.e. OAuth is configured); bearer-only layers
326    // have no scope catalog to advertise.
327    let scope = layer
328        .auth_state
329        .as_ref()
330        .map(|s| s.config.scopes_supported.join(" "));
331
332    if let Some(token) = auth_header {
333        // 1. Static bearer match — skipped when the consumer has set
334        //    `disable_static_token_with_oauth=true` and OAuth mode is active.
335        let static_token_blocked = layer.auth_state.as_ref().is_some_and(|s| {
336            s.config.disable_static_token_with_oauth
337                && matches!(s.config.mode, crate::config::AuthMode::OAuth)
338        });
339        if !static_token_blocked
340            && let Some(ref expected) = layer.static_token
341            && tokens_equal(&token, expected.as_ref())
342        {
343            let sub = "static-bearer".to_string();
344            let actor_key = derive_actor_key(layer.actor_key_deriver.as_deref(), &sub);
345            request.extensions_mut().insert(AuthContext {
346                sub,
347                actor_key,
348                scopes: layer.static_token_scopes.clone(),
349                issuer: "local".to_string(),
350                via_session: false,
351                csrf_token: None,
352                email: None,
353            });
354            return Ok(request);
355        }
356
357        // 2. JWT validation.
358        if let Some(ref auth_state) = layer.auth_state {
359            let Some(expected_issuer) = auth_state
360                .config
361                .public_url
362                .as_ref()
363                .map(|url| url.as_str().trim_end_matches('/').to_string())
364            else {
365                return Err(auth_error_response(
366                    &format!(
367                        "server misconfigured: {}_PUBLIC_URL required for JWT validation",
368                        auth_state.config.env_prefix
369                    ),
370                    layer.resource_url.as_deref(),
371                    scope.as_deref(),
372                ));
373            };
374            let expected_aud = canonical_resource_url(auth_state);
375            match auth_state.signing_keys.validate_access_token_with_issuer(
376                &token,
377                &expected_aud,
378                &expected_issuer,
379            ) {
380                Ok(claims) => {
381                    let actor_key =
382                        derive_actor_key(layer.actor_key_deriver.as_deref(), &claims.sub);
383                    request.extensions_mut().insert(AuthContext {
384                        actor_key,
385                        sub: claims.sub,
386                        scopes: claims
387                            .scope
388                            .split_whitespace()
389                            .filter(|scope| !scope.is_empty())
390                            .map(ToOwned::to_owned)
391                            .collect(),
392                        issuer: claims.iss,
393                        via_session: false,
394                        csrf_token: None,
395                        email: None,
396                    });
397                    return Ok(request);
398                }
399                Err(error) => {
400                    tracing::debug!(error = %error, "lab-auth JWT validation failed");
401                }
402            }
403        }
404
405        // Rejected bearer: surface a rate-limited warn with a token
406        // fingerprint (never the token) so brute-force attempts are
407        // operator-visible. Peer address is logged when the consumer
408        // serves with `into_make_service_with_connect_info`.
409        let token_fp = crate::util::fingerprint(&token);
410        if layer.failed_auth_log.should_log(&token_fp) {
411            let peer = request
412                .extensions()
413                .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
414                .map(|info| info.0.to_string());
415            tracing::warn!(
416                token_fp = %token_fp,
417                peer = peer.as_deref().unwrap_or("unknown"),
418                "rejected bearer token"
419            );
420        }
421        return Err(auth_error_response(
422            "invalid bearer token",
423            layer.resource_url.as_deref(),
424            scope.as_deref(),
425        ));
426    }
427
428    // 3. Browser session cookie path.
429    if layer.allow_session_cookie
430        && let Some(auth_state) = layer.auth_state.as_ref()
431        && let Some(session_id) =
432            session::read_cookie(request.headers(), &layer.session_cookie_name)
433    {
434        match auth_state.store.find_browser_session(&session_id).await {
435            Ok(Some(session)) => {
436                if !matches!(
437                    *request.method(),
438                    Method::GET | Method::HEAD | Method::OPTIONS
439                ) {
440                    let csrf_ok = request
441                        .headers()
442                        .get(session::BROWSER_CSRF_HEADER_NAME)
443                        .and_then(|value| value.to_str().ok())
444                        .is_some_and(|csrf| tokens_equal(csrf, &session.csrf_token));
445                    if !csrf_ok {
446                        return Err(csrf_error_response("missing or invalid csrf token"));
447                    }
448                }
449
450                let actor_key =
451                    derive_actor_key(layer.actor_key_deriver.as_deref(), &session.subject);
452                request.extensions_mut().insert(AuthContext {
453                    actor_key,
454                    sub: session.subject,
455                    scopes: layer.static_token_scopes.clone(),
456                    issuer: "browser-session".to_string(),
457                    via_session: true,
458                    csrf_token: Some(session.csrf_token),
459                    email: session.email,
460                });
461                return Ok(request);
462            }
463            Ok(None) => {}
464            Err(error) => {
465                tracing::debug!(error = %error, "browser session lookup failed");
466            }
467        }
468    }
469
470    // 4. Browser GET → redirect to login_path.
471    if layer.allow_session_cookie
472        && layer.auth_state.is_some()
473        && *request.method() == Method::GET
474        && request
475            .headers()
476            .get(header::ACCEPT)
477            .and_then(|v| v.to_str().ok())
478            .is_some_and(|accept| accept.contains("text/html"))
479    {
480        let return_to = request
481            .uri()
482            .path_and_query()
483            .map(|pq| pq.as_str())
484            .unwrap_or("/");
485        let encoded = percent_encode_path(return_to);
486        let login_url = format!("{}?return_to={encoded}", layer.login_path);
487        return Err(Redirect::to(&login_url).into_response());
488    }
489
490    Err(auth_error_response(
491        if layer.allow_session_cookie {
492            "missing bearer token or session cookie"
493        } else {
494            "missing bearer token"
495        },
496        layer.resource_url.as_deref(),
497        scope.as_deref(),
498    ))
499}
500
501/// Constant-time byte comparison for static-bearer matching (prevents
502/// timing-based prefix leakage).
503#[must_use]
504pub fn tokens_equal(a: &str, b: &str) -> bool {
505    a.as_bytes().ct_eq(b.as_bytes()).into()
506}
507
508/// Parse a single `Authorization: Bearer <token>` header value, returning
509/// `None` for malformed or non-Bearer schemes.
510#[must_use]
511pub fn parse_bearer_token(header_value: &str) -> Option<String> {
512    let mut parts = header_value.split_whitespace();
513    let scheme = parts.next()?;
514    let token = parts.next()?;
515    if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
516        return None;
517    }
518    Some(token.to_string())
519}
520
521fn derive_actor_key(deriver: Option<&ActorKeyDeriver>, subject: &str) -> Option<Arc<str>> {
522    deriver.and_then(|deriver| deriver(subject))
523}
524
525/// Build a 401 response wrapping [`AuthError::AuthFailed`] and decorate it
526/// with `WWW-Authenticate` when a `resource_url` was supplied. `scope`, when
527/// present, is threaded into the `scope="..."` parameter (see
528/// [`www_authenticate_value`]).
529fn auth_error_response(message: &str, resource_url: Option<&str>, scope: Option<&str>) -> Response {
530    let mut response = AuthError::AuthFailed(message.to_string()).into_response();
531    if let Some(url) = resource_url {
532        let www_auth = www_authenticate_value(url, scope);
533        if let Ok(value) = HeaderValue::from_str(&www_auth) {
534            response
535                .headers_mut()
536                .insert(header::WWW_AUTHENTICATE, value);
537        }
538    }
539    response
540}
541
542fn csrf_error_response(message: &str) -> Response {
543    AuthError::Validation(message.to_string()).into_response()
544}
545
546fn percent_encode_path(s: &str) -> String {
547    let mut out = String::with_capacity(s.len());
548    for b in s.bytes() {
549        // Do NOT include `?` here — when return_to is used as a query-string
550        // value a literal `?` would be interpreted as the start of a nested
551        // query string by the redirect target.
552        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {
553            out.push(b as char);
554        } else {
555            out.push('%');
556            out.push(
557                char::from_digit(u32::from(b >> 4), 16)
558                    .unwrap()
559                    .to_ascii_uppercase(),
560            );
561            out.push(
562                char::from_digit(u32::from(b & 0xf), 16)
563                    .unwrap()
564                    .to_ascii_uppercase(),
565            );
566        }
567    }
568    out
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use axum::Router;
575    use axum::http::{Request as HttpRequest, StatusCode};
576    use axum::routing::get;
577    use tower::ServiceExt;
578
579    use crate::authorize::tests::{test_auth_config, test_auth_state, test_auth_state_with_config};
580
581    fn echo_app(layer: AuthLayer) -> Router {
582        Router::new()
583            .route("/probe", get(|| async { "ok" }))
584            .route_layer(layer)
585    }
586
587    #[test]
588    fn parse_bearer_token_accepts_valid_header() {
589        assert_eq!(
590            parse_bearer_token("Bearer abc.def").as_deref(),
591            Some("abc.def")
592        );
593        assert_eq!(
594            parse_bearer_token("bearer abc.def").as_deref(),
595            Some("abc.def")
596        );
597    }
598
599    #[test]
600    fn parse_bearer_token_rejects_malformed() {
601        assert_eq!(parse_bearer_token("Basic abc.def"), None);
602        assert_eq!(parse_bearer_token("Bearer"), None);
603        assert_eq!(parse_bearer_token("Bearer one two"), None);
604        assert_eq!(parse_bearer_token(""), None);
605    }
606
607    #[test]
608    fn tokens_equal_distinguishes_unequal_strings() {
609        assert!(tokens_equal("abc", "abc"));
610        assert!(!tokens_equal("abc", "abd"));
611        assert!(!tokens_equal("abc", "abcd"));
612    }
613
614    #[test]
615    fn failed_auth_log_rate_limits_per_fingerprint() {
616        let log = FailedAuthLog::new();
617        assert!(log.should_log("aaaaaaaaaaaa"));
618        assert!(
619            !log.should_log("aaaaaaaaaaaa"),
620            "second rejection of the same fingerprint inside the window must be suppressed"
621        );
622        assert!(
623            log.should_log("bbbbbbbbbbbb"),
624            "a different fingerprint gets its own window"
625        );
626    }
627
628    #[tokio::test(flavor = "current_thread")]
629    async fn missing_bearer_token_returns_401_with_www_authenticate() {
630        let layer =
631            AuthLayer::new().with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
632        let app = echo_app(layer);
633        let response = app
634            .oneshot(
635                HttpRequest::builder()
636                    .uri("/probe")
637                    .body(Body::empty())
638                    .unwrap(),
639            )
640            .await
641            .unwrap();
642        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
643        let www = response
644            .headers()
645            .get(header::WWW_AUTHENTICATE)
646            .and_then(|v| v.to_str().ok())
647            .unwrap_or_default();
648        assert!(
649            www.contains("resource_metadata="),
650            "missing resource_metadata in WWW-Authenticate: `{www}`"
651        );
652        assert!(
653            !www.contains("scope="),
654            "unexpected scope in WWW-Authenticate for a layer with no auth_state: `{www}`"
655        );
656    }
657
658    #[tokio::test(flavor = "current_thread")]
659    async fn missing_bearer_token_with_auth_state_includes_scope_in_www_authenticate() {
660        let state = Arc::new(test_auth_state().await);
661        let layer = AuthLayer::from_state(state)
662            .with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
663        let app = echo_app(layer);
664        let response = app
665            .oneshot(
666                HttpRequest::builder()
667                    .uri("/probe")
668                    .body(Body::empty())
669                    .unwrap(),
670            )
671            .await
672            .unwrap();
673        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
674        let www = response
675            .headers()
676            .get(header::WWW_AUTHENTICATE)
677            .and_then(|v| v.to_str().ok())
678            .unwrap_or_default();
679        assert!(
680            www.contains("resource_metadata="),
681            "missing resource_metadata in WWW-Authenticate: `{www}`"
682        );
683        assert!(
684            www.contains("scope="),
685            "missing scope in WWW-Authenticate: `{www}`"
686        );
687    }
688
689    #[tokio::test(flavor = "current_thread")]
690    async fn static_bearer_match_grants_configured_scopes() {
691        let token: Arc<str> = Arc::<str>::from("super-secret");
692        let layer = AuthLayer::new()
693            .with_static_token(Some(token.clone()))
694            .with_static_token_scopes(vec!["syslog:read".to_string(), "syslog:admin".to_string()]);
695        let app = Router::new()
696            .route(
697                "/probe",
698                get(
699                    |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
700                        ctx.scopes.join(",")
701                    },
702                ),
703            )
704            .route_layer(layer);
705
706        let response = app
707            .oneshot(
708                HttpRequest::builder()
709                    .uri("/probe")
710                    .header(header::AUTHORIZATION, "Bearer super-secret")
711                    .body(Body::empty())
712                    .unwrap(),
713            )
714            .await
715            .unwrap();
716        assert_eq!(response.status(), StatusCode::OK);
717        let body = axum::body::to_bytes(response.into_body(), 1024)
718            .await
719            .unwrap();
720        assert_eq!(&body[..], b"syslog:read,syslog:admin");
721    }
722
723    #[tokio::test(flavor = "current_thread")]
724    async fn wrong_static_bearer_rejected() {
725        let layer = AuthLayer::new().with_static_token(Some(Arc::<str>::from("super-secret")));
726        let app = echo_app(layer);
727
728        let response = app
729            .oneshot(
730                HttpRequest::builder()
731                    .uri("/probe")
732                    .header(header::AUTHORIZATION, "Bearer wrong")
733                    .body(Body::empty())
734                    .unwrap(),
735            )
736            .await
737            .unwrap();
738        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
739    }
740
741    #[tokio::test(flavor = "current_thread")]
742    async fn jwt_validation_path_accepts_signed_token_and_writes_context() {
743        let state = Arc::new(test_auth_state().await);
744        let aud = canonical_resource_url(&state);
745        let iss = state
746            .config
747            .public_url
748            .as_ref()
749            .map(|url| url.as_str().trim_end_matches('/').to_string())
750            .unwrap();
751        let claims = crate::jwt::AccessClaims {
752            iss: iss.clone(),
753            sub: "user@example.com".to_string(),
754            aud: aud.clone(),
755            exp: (crate::util::now_unix() + 60) as usize,
756            iat: crate::util::now_unix() as usize,
757            jti: "j-1".to_string(),
758            scope: "syslog:read syslog:admin".to_string(),
759            azp: String::new(),
760        };
761        let token = state.signing_keys.issue_access_token(&claims).unwrap();
762        let layer = AuthLayer::from_state(state);
763        let app = Router::new()
764            .route(
765                "/probe",
766                get(
767                    |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
768                        format!("{}|{}", ctx.sub, ctx.scopes.join(","))
769                    },
770                ),
771            )
772            .route_layer(layer);
773
774        let response = app
775            .oneshot(
776                HttpRequest::builder()
777                    .uri("/probe")
778                    .header(header::AUTHORIZATION, format!("Bearer {token}"))
779                    .body(Body::empty())
780                    .unwrap(),
781            )
782            .await
783            .unwrap();
784        assert_eq!(response.status(), StatusCode::OK);
785        let body = axum::body::to_bytes(response.into_body(), 1024)
786            .await
787            .unwrap();
788        assert_eq!(&body[..], b"user@example.com|syslog:read,syslog:admin");
789    }
790
791    #[tokio::test(flavor = "current_thread")]
792    async fn jwt_with_wrong_issuer_rejected() {
793        let state = Arc::new(test_auth_state().await);
794        let aud = canonical_resource_url(&state);
795        let claims = crate::jwt::AccessClaims {
796            iss: "https://attacker.example.com".to_string(),
797            sub: "user@example.com".to_string(),
798            aud,
799            exp: (crate::util::now_unix() + 60) as usize,
800            iat: crate::util::now_unix() as usize,
801            jti: "j-1".to_string(),
802            scope: "syslog:read".to_string(),
803            azp: String::new(),
804        };
805        let token = state.signing_keys.issue_access_token(&claims).unwrap();
806        let layer = AuthLayer::from_state(state)
807            .with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
808        let app = echo_app(layer);
809
810        let response = app
811            .oneshot(
812                HttpRequest::builder()
813                    .uri("/probe")
814                    .header(header::AUTHORIZATION, format!("Bearer {token}"))
815                    .body(Body::empty())
816                    .unwrap(),
817            )
818            .await
819            .unwrap();
820        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
821    }
822
823    #[tokio::test(flavor = "current_thread")]
824    async fn jwt_with_wrong_audience_rejected() {
825        let state = Arc::new(test_auth_state().await);
826        let iss = state
827            .config
828            .public_url
829            .as_ref()
830            .map(|url| url.as_str().trim_end_matches('/').to_string())
831            .unwrap();
832        let claims = crate::jwt::AccessClaims {
833            iss,
834            sub: "user@example.com".to_string(),
835            aud: "https://other.example.com/mcp".to_string(),
836            exp: (crate::util::now_unix() + 60) as usize,
837            iat: crate::util::now_unix() as usize,
838            jti: "j-1".to_string(),
839            scope: "syslog:read".to_string(),
840            azp: String::new(),
841        };
842        let token = state.signing_keys.issue_access_token(&claims).unwrap();
843        let layer = AuthLayer::from_state(state);
844        let app = echo_app(layer);
845
846        let response = app
847            .oneshot(
848                HttpRequest::builder()
849                    .uri("/probe")
850                    .header(header::AUTHORIZATION, format!("Bearer {token}"))
851                    .body(Body::empty())
852                    .unwrap(),
853            )
854            .await
855            .unwrap();
856        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
857    }
858
859    #[tokio::test(flavor = "current_thread")]
860    async fn html_get_with_session_cookie_enabled_redirects_to_login_path() {
861        let state = Arc::new(test_auth_state().await);
862        let layer = AuthLayer::from_state(state)
863            .with_allow_session_cookie(true)
864            .with_login_path("/auth/login");
865        let app = echo_app(layer);
866
867        let response = app
868            .oneshot(
869                HttpRequest::builder()
870                    .uri("/probe?x=1")
871                    .header(header::ACCEPT, "text/html")
872                    .body(Body::empty())
873                    .unwrap(),
874            )
875            .await
876            .unwrap();
877        assert_eq!(response.status(), StatusCode::SEE_OTHER);
878        let location = response
879            .headers()
880            .get(header::LOCATION)
881            .and_then(|v| v.to_str().ok())
882            .unwrap_or_default();
883        assert!(
884            location.starts_with("/auth/login?return_to="),
885            "unexpected redirect Location: `{location}`"
886        );
887    }
888
889    #[tokio::test(flavor = "current_thread")]
890    async fn html_get_uses_configured_login_path_override() {
891        let state = Arc::new(test_auth_state().await);
892        let layer = AuthLayer::from_state(state)
893            .with_allow_session_cookie(true)
894            .with_login_path("/syslog/auth/login");
895        let app = echo_app(layer);
896
897        let response = app
898            .oneshot(
899                HttpRequest::builder()
900                    .uri("/probe")
901                    .header(header::ACCEPT, "text/html")
902                    .body(Body::empty())
903                    .unwrap(),
904            )
905            .await
906            .unwrap();
907        assert_eq!(response.status(), StatusCode::SEE_OTHER);
908        let location = response
909            .headers()
910            .get(header::LOCATION)
911            .and_then(|v| v.to_str().ok())
912            .unwrap_or_default();
913        assert!(
914            location.starts_with("/syslog/auth/login?return_to="),
915            "unexpected redirect Location: `{location}`"
916        );
917    }
918
919    #[tokio::test(flavor = "current_thread")]
920    async fn static_bearer_blocked_when_disable_static_token_with_oauth_is_set() {
921        // When disable_static_token_with_oauth=true and mode=OAuth, the static
922        // token must be rejected even though the token value matches.
923        let mut config = test_auth_config();
924        config.disable_static_token_with_oauth = true;
925        let state = Arc::new(test_auth_state_with_config(config).await);
926
927        let token: Arc<str> = Arc::from("super-secret");
928        let layer = AuthLayer::from_state(state).with_static_token(Some(token.clone()));
929        let app = echo_app(layer);
930
931        let response = app
932            .oneshot(
933                HttpRequest::builder()
934                    .uri("/probe")
935                    .header(header::AUTHORIZATION, "Bearer super-secret")
936                    .body(Body::empty())
937                    .unwrap(),
938            )
939            .await
940            .unwrap();
941        // Must be 401 — static token blocked because OAuth is active.
942        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
943    }
944
945    /// Router that echoes `sub:via_session` from the injected AuthContext.
946    fn session_probe_app(layer: AuthLayer) -> Router {
947        Router::new()
948            .route(
949                "/probe",
950                get(
951                    |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
952                        format!("{}:{}", ctx.sub, ctx.via_session)
953                    },
954                )
955                .post(
956                    |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
957                        format!("{}:{}", ctx.sub, ctx.via_session)
958                    },
959                ),
960            )
961            .route_layer(layer)
962    }
963
964    async fn state_with_session() -> (Arc<AuthState>, crate::types::BrowserSessionRow) {
965        let state = Arc::new(test_auth_state().await);
966        let session = session::create_browser_session(&state, "cookie-user".to_string(), None)
967            .await
968            .expect("create session");
969        (state, session)
970    }
971
972    #[tokio::test(flavor = "current_thread")]
973    async fn valid_session_cookie_authenticates_get_and_marks_via_session() {
974        let (state, session) = state_with_session().await;
975        let cookie = format!(
976            "{}={}",
977            state.config.session_cookie_name, session.session_id
978        );
979        let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
980
981        let response = app
982            .oneshot(
983                HttpRequest::builder()
984                    .uri("/probe")
985                    .header(header::COOKIE, cookie)
986                    .body(Body::empty())
987                    .unwrap(),
988            )
989            .await
990            .unwrap();
991        assert_eq!(response.status(), StatusCode::OK);
992        let body = axum::body::to_bytes(response.into_body(), 1024)
993            .await
994            .unwrap();
995        assert_eq!(&body[..], b"cookie-user:true");
996    }
997
998    #[tokio::test(flavor = "current_thread")]
999    async fn session_cookie_post_without_csrf_header_is_rejected() {
1000        let (state, session) = state_with_session().await;
1001        let cookie = format!(
1002            "{}={}",
1003            state.config.session_cookie_name, session.session_id
1004        );
1005        let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1006
1007        let response = app
1008            .oneshot(
1009                HttpRequest::builder()
1010                    .method(Method::POST)
1011                    .uri("/probe")
1012                    .header(header::COOKIE, cookie)
1013                    .body(Body::empty())
1014                    .unwrap(),
1015            )
1016            .await
1017            .unwrap();
1018        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1019    }
1020
1021    #[tokio::test(flavor = "current_thread")]
1022    async fn session_cookie_post_with_wrong_csrf_header_is_rejected() {
1023        let (state, session) = state_with_session().await;
1024        let cookie = format!(
1025            "{}={}",
1026            state.config.session_cookie_name, session.session_id
1027        );
1028        let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1029
1030        let response = app
1031            .oneshot(
1032                HttpRequest::builder()
1033                    .method(Method::POST)
1034                    .uri("/probe")
1035                    .header(header::COOKIE, cookie)
1036                    .header(session::BROWSER_CSRF_HEADER_NAME, "not-the-csrf-token")
1037                    .body(Body::empty())
1038                    .unwrap(),
1039            )
1040            .await
1041            .unwrap();
1042        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1043    }
1044
1045    #[tokio::test(flavor = "current_thread")]
1046    async fn session_cookie_post_with_matching_csrf_header_is_accepted() {
1047        let (state, session) = state_with_session().await;
1048        let cookie = format!(
1049            "{}={}",
1050            state.config.session_cookie_name, session.session_id
1051        );
1052        let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1053
1054        let response = app
1055            .oneshot(
1056                HttpRequest::builder()
1057                    .method(Method::POST)
1058                    .uri("/probe")
1059                    .header(header::COOKIE, cookie)
1060                    .header(
1061                        session::BROWSER_CSRF_HEADER_NAME,
1062                        session.csrf_token.as_str(),
1063                    )
1064                    .body(Body::empty())
1065                    .unwrap(),
1066            )
1067            .await
1068            .unwrap();
1069        assert_eq!(response.status(), StatusCode::OK);
1070        let body = axum::body::to_bytes(response.into_body(), 1024)
1071            .await
1072            .unwrap();
1073        assert_eq!(&body[..], b"cookie-user:true");
1074    }
1075}